diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9488d848..ca7f9d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,6 @@ on: - "Sources/**" - "CLI/**" - "CLI-MCP/**" - - "daemon/**" - "programaTests/**" - "tests/**" - "tests_v2/**" @@ -64,7 +63,6 @@ jobs: runs-on: ubuntu-latest outputs: run_app_jobs: ${{ steps.detect.outputs.run_app_jobs }} - run_remote_daemon_jobs: ${{ steps.detect.outputs.run_remote_daemon_jobs }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -88,7 +86,6 @@ jobs: # Keep full CI for push events and non-PR runs. if [[ "${{ github.event_name }}" != "pull_request" ]]; then echo "run_app_jobs=true" >> "$GITHUB_OUTPUT" - echo "run_remote_daemon_jobs=true" >> "$GITHUB_OUTPUT" echo "Changed files: full suite (non-PR context)" >> "$GITHUB_STEP_SUMMARY" exit 0 fi @@ -99,9 +96,8 @@ jobs: CHANGED_FILES="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")" CLASSIFICATION="$(printf '%s' "$CHANGED_FILES" | ./scripts/classify_ci_changes.sh)" APP_OUTPUT="${CLASSIFICATION%%$'\n'*}" - REMOTE_OUTPUT="${CLASSIFICATION#*$'\n'}" - if [[ "$APP_OUTPUT" == "$CLASSIFICATION" || "$REMOTE_OUTPUT" == *$'\n'* ]]; then + if [[ "$APP_OUTPUT" != "$CLASSIFICATION" ]]; then echo "CI change classifier returned an invalid output shape" >&2 exit 1 fi @@ -115,24 +111,13 @@ jobs: ;; esac - case "$REMOTE_OUTPUT" in - run_remote_daemon_jobs=true) RUN_REMOTE_DAEMON_JOBS=true ;; - run_remote_daemon_jobs=false) RUN_REMOTE_DAEMON_JOBS=false ;; - *) - echo "CI change classifier returned an invalid daemon result" >&2 - exit 1 - ;; - esac - printf '%s\n' "$CLASSIFICATION" >> "$GITHUB_OUTPUT" { echo "### CI scope decision" if [[ "$RUN_APP_JOBS" == "true" ]]; then echo "Changed file set contains app-relevant changes; running full macOS jobs." - elif [[ "$RUN_REMOTE_DAEMON_JOBS" == "true" ]]; then - echo "Changed file set contains daemon-only changes; running remote daemon jobs." else - echo "Pull request appears docs/workflow/localization-only; skipping heavy app/daemon jobs." + echo "Pull request appears docs/workflow/localization-only; skipping heavy app jobs." fi echo "" echo "Changed files:" @@ -187,26 +172,6 @@ jobs: - name: Validate Sparkle enclosure naming and pruning run: node --test scripts/sparkle_enclosure.test.js - remote-daemon-tests: - needs: change-detection - if: needs.change-detection.outputs.run_remote_daemon_jobs == 'true' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version-file: daemon/remote/go.mod - - - name: Run remote daemon tests - working-directory: daemon/remote - run: go test ./... - - - name: Validate remote daemon release assets - run: ./tests/test_remote_daemon_release_assets.sh - unit-tests: needs: change-detection if: needs.change-detection.outputs.run_app_jobs == 'true' diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml deleted file mode 100644 index 259606dd..00000000 --- a/.github/workflows/ios-build.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: iOS build - -# Compile check for the iOS companion app. Deliberately UNSIGNED, so it needs no -# secrets and stays green even while the TestFlight signing material is not yet -# configured — the point is to catch "the companion no longer compiles" at PR -# time, which is otherwise invisible: ci.yml does not build ios/** at all. -# -# This lives in its own workflow rather than as a job in ci.yml on purpose. -# ci.yml's change-detection treats any non-docs path as app-relevant and turns on -# the full macOS suite (unit tests, socket integration, typing-lag, UI -# regressions). Adding ios/** to its path filter would run all of that for an -# iOS-only change, which is minutes of macOS runner time for no signal. -on: - push: - branches: - - main - paths: - - "ios/**" - - "tools/mobile-spike/**" - - "vendor/CmuxIrohTransport/**" - - "scripts/build-ios-testflight.sh" - - ".github/workflows/ios-build.yml" - pull_request: - paths: - - "ios/**" - - "tools/mobile-spike/**" - - "vendor/CmuxIrohTransport/**" - - "scripts/build-ios-testflight.sh" - - ".github/workflows/ios-build.yml" - -concurrency: - group: ios-build-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build companion (unsigned) - runs-on: macos-15 - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # No submodules: the companion depends on vendor/CmuxIrohTransport - # (vendored in-tree) and iroh-ffi via SwiftPM. It does not need ghostty. - - - name: Select Xcode - run: | - set -euo pipefail - XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1)" - if [ -z "$XCODE_APP" ]; then XCODE_APP="/Applications/Xcode.app"; fi - sudo xcode-select -s "$XCODE_APP/Contents/Developer" - echo "DEVELOPER_DIR=$XCODE_APP/Contents/Developer" >> "$GITHUB_ENV" - echo "Selected: $XCODE_APP" - - - name: Install xcodegen - run: brew install xcodegen - - - name: Generate project - run: | - set -euo pipefail - cd ios/ProgramaSpike - xcodegen generate - - # iroh-ffi resolves to a checksummed prebuilt binary xcframework, so this - # is a download rather than a Rust build, but it is still worth caching. - - name: Cache SwiftPM - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: .ios-build/source-packages - key: ios-spm-${{ runner.os }}-${{ hashFiles('ios/ProgramaSpike/project.yml') }} - restore-keys: | - ios-spm-${{ runner.os }}- - - - name: Build - run: | - set -euo pipefail - # Device destination rather than a simulator one: it matches what the - # TestFlight archive actually builds (arm64 device slice), and avoids - # depending on which simulator runtimes happen to be installed on the - # runner image. - xcodebuild \ - -project ios/ProgramaSpike/ProgramaSpike.xcodeproj \ - -scheme ProgramaSpike \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -clonedSourcePackagesDirPath .ios-build/source-packages \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY="" \ - build - - - name: Test - run: | - set -euo pipefail - # Pin the simulator model instead of relying on xcodebuild's generic - # destination selection, which can choose a different runtime/device - # as the hosted image changes. `-only-testing` also fails loudly if - # the generated app scheme ever stops including ProgramaSpikeTests. - # Keep the simulator's normal ad-hoc signing enabled: the hosted app - # exercises CloudKit during launch and needs its declared entitlements. - xcodebuild \ - -project ios/ProgramaSpike/ProgramaSpike.xcodeproj \ - -scheme ProgramaSpike \ - -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ - -derivedDataPath "$RUNNER_TEMP/programa-ios-tests-derived-data" \ - -clonedSourcePackagesDirPath .ios-build/source-packages \ - -only-testing:ProgramaSpikeTests \ - test - - - name: Test shared mobile framing - run: swift test --package-path tools/mobile-spike diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml deleted file mode 100644 index eb52b6e5..00000000 --- a/.github/workflows/ios-testflight.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: iOS TestFlight - -# Auto-ships the companion, matching the macOS lane's philosophy: anything that -# lands on main goes out, and breakage is fixed forward rather than gated behind -# a manual step nobody remembers to run. (This lane was manual-only until the -# companion was actually in testers' hands; it never once ran, which is precisely -# the failure mode that argues for automation.) -# -# Path-filtered rather than triggered on CI success. Two reasons: a macOS CI pass -# says nothing about whether the companion compiles (this lane shares no build -# with it and does not even check out the ghostty submodule), and a macOS-only -# merge should not spend ~45 minutes of macOS runner time or push a pointless -# build to testers. `push` supports paths natively, so no commit-range guesswork. -# -# A push here always uploads. workflow_dispatch still defaults to build-and-verify -# only, which is the safe way to check the pipeline without consuming a build -# number or notifying testers. -on: - push: - branches: - - main - paths: - - "ios/**" - - "vendor/CmuxIrohTransport/**" - - "scripts/build-ios-testflight.sh" - - ".github/workflows/ios-testflight.yml" - workflow_dispatch: - inputs: - upload: - description: "Upload to TestFlight (false = build and verify only)" - type: boolean - default: false - -concurrency: - # Build numbers must be monotonic and are derived from the run id, so two - # concurrent runs would race for the same slot in App Store Connect. - group: ios-testflight - cancel-in-progress: false - -jobs: - # Decide, on a free ubuntu runner, whether this repo can sign at all — before - # spending a macOS runner on an archive that cannot be exported. - # - # The two trigger types want opposite behaviour when credentials are absent: - # push — "not configured yet" is a known setup state, not a regression. A red - # X on main for it trains people to ignore red, so warn loudly and skip. - # dispatch — someone deliberately asked to ship. Fail so they see why. - preflight: - runs-on: ubuntu-latest - outputs: - configured: ${{ steps.check.outputs.configured }} - steps: - - name: Check signing material - id: check - env: - IOS_DIST_CERT_BASE64: ${{ secrets.APPLE_IOS_DIST_CERT_BASE64 }} - IOS_DIST_CERT_PASSWORD: ${{ secrets.APPLE_IOS_DIST_CERT_PASSWORD }} - IOS_APP_PROFILE_BASE64: ${{ secrets.APPLE_IOS_APP_PROFILE_BASE64 }} - IOS_WIDGET_PROFILE_BASE64: ${{ secrets.APPLE_IOS_WIDGET_PROFILE_BASE64 }} - ASC_KEY_ID: ${{ secrets.APPSTORE_CONNECT_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_ISSUER_ID }} - ASC_KEY_P8_BASE64: ${{ secrets.APPSTORE_CONNECT_KEY_P8_BASE64 }} - WILL_UPLOAD: ${{ (github.event_name == 'push' || inputs.upload) && '1' || '0' }} - run: | - set -euo pipefail - missing="" - for v in IOS_DIST_CERT_BASE64 IOS_DIST_CERT_PASSWORD IOS_APP_PROFILE_BASE64 IOS_WIDGET_PROFILE_BASE64; do - if [ -z "${!v}" ]; then missing="$missing APPLE_$v"; fi - done - - # Check the upload credentials here too, when this run would upload. - # build-ios-testflight.sh only requires them at its very last step, so a - # repo with the four signing secrets but not the three App Store Connect - # ones used to report "configured", spend 10+ minutes on a macOS runner - # archiving, exporting and verifying entitlements, and only then die on - # `require PROGRAMA_ASC_KEY_ID`. Fail in seconds instead. - if [ "$WILL_UPLOAD" = "1" ]; then - for v in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8_BASE64; do - if [ -z "${!v}" ]; then missing="$missing APPSTORE_CONNECT_${v#ASC_}"; fi - done - fi - - if [ -z "$missing" ]; then - echo "configured=true" >> "$GITHUB_OUTPUT" - echo "Signing material present." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - echo "configured=false" >> "$GITHUB_OUTPUT" - - # Spell out the fix rather than the symptom: this cannot be fixed by - # editing the repo. The material has to be exported from the Apple - # Developer portal by a human with account access. - { - echo "## Companion NOT shipped — iOS signing is not configured" - echo "" - echo "Missing repository secret(s):\`$missing\`" - echo "" - echo "Add these under **Settings > Secrets and variables > Actions**:" - echo "" - echo '| secret | what it is |' - echo '|---|---|' - echo '| `APPLE_IOS_DIST_CERT_BASE64` | Apple **Distribution** certificate as `.p12`, base64. A Developer ID or Apple Development cert cannot sign for TestFlight. |' - echo '| `APPLE_IOS_DIST_CERT_PASSWORD` | the `.p12` export password |' - echo '| `APPLE_IOS_APP_PROFILE_BASE64` | **App Store** profile for `com.darkroom.programa.spike`, base64. Must be minted with Push Notifications enabled on the App ID, or the build fails later on `aps-environment != production`. |' - echo '| `APPLE_IOS_WIDGET_PROFILE_BASE64` | App Store profile for `com.darkroom.programa.spike.widgets` |' - echo '| `APPSTORE_CONNECT_KEY_ID` | App Store Connect API key id (upload only) |' - echo '| `APPSTORE_CONNECT_ISSUER_ID` | App Store Connect issuer id (upload only) |' - echo '| `APPSTORE_CONNECT_KEY_P8_BASE64` | the `.p8` key, base64 (upload only) |' - echo "" - echo 'Encode each file with `base64 -i | pbcopy`.' - echo "" - echo "### Two things the secrets alone will not fix" - echo "" - echo "1. **Regenerate both provisioning profiles, do not reuse the existing ones.**" - echo " The current profiles were minted before the iCloud container was attached" - echo " to the App ID, so they carry no containers. Enable Push Notifications on" - echo " the App ID first, then mint fresh App Store profiles — otherwise the build" - echo " gets as far as signing and then fails its own entitlement verification on" - echo ' `aps-environment != production`.' - echo "2. **An App Store Connect app record must already exist** for" - echo ' `com.darkroom.programa.spike`. Nothing in this repo creates one, and' - echo ' `altool` rejects the upload without it. Create it once under My Apps.' - } >> "$GITHUB_STEP_SUMMARY" - - echo "::warning title=iOS companion not shipped::Signing secrets are missing, so no TestFlight build was produced. See the run summary for the exact list." - - if [ "${{ github.event_name }}" != "push" ]; then - echo "Refusing an explicitly requested ship with no signing material." >&2 - exit 1 - fi - echo "Automatic trigger: skipping the build rather than failing main." - - build: - needs: preflight - if: needs.preflight.outputs.configured == 'true' - runs-on: macos-15 - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # No submodules: the iOS app depends on vendor/CmuxIrohTransport, which is - # vendored in-tree, and on iroh-ffi via SwiftPM. It does not need the - # ghostty submodule, and skipping it saves several minutes. - - - name: Select Xcode - run: | - set -euo pipefail - XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1)" - if [ -z "$XCODE_APP" ]; then XCODE_APP="/Applications/Xcode.app"; fi - sudo xcode-select -s "$XCODE_APP/Contents/Developer" - echo "DEVELOPER_DIR=$XCODE_APP/Contents/Developer" >> "$GITHUB_ENV" - echo "Selected: $XCODE_APP" - - - name: Install xcodegen - run: brew install xcodegen - - - name: Materialise signing assets - env: - IOS_DIST_CERT_BASE64: ${{ secrets.APPLE_IOS_DIST_CERT_BASE64 }} - IOS_APP_PROFILE_BASE64: ${{ secrets.APPLE_IOS_APP_PROFILE_BASE64 }} - IOS_WIDGET_PROFILE_BASE64: ${{ secrets.APPLE_IOS_WIDGET_PROFILE_BASE64 }} - ASC_KEY_P8_BASE64: ${{ secrets.APPSTORE_CONNECT_KEY_P8_BASE64 }} - run: | - set -euo pipefail - # The preflight job already gated on these and explained how to supply - # them, so this is only defence in depth against the two jobs drifting - # apart. Keep it terse; preflight owns the guidance. - for v in IOS_DIST_CERT_BASE64 IOS_APP_PROFILE_BASE64 IOS_WIDGET_PROFILE_BASE64; do - if [ -z "${!v}" ]; then - echo "APPLE_$v is empty but preflight reported configured=true." >&2 - exit 1 - fi - done - mkdir -p "$RUNNER_TEMP/signing" - echo "$IOS_DIST_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/dist.p12" - echo "$IOS_APP_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/app.mobileprovision" - echo "$IOS_WIDGET_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/widget.mobileprovision" - if [ -n "$ASC_KEY_P8_BASE64" ]; then - echo "$ASC_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/signing/asc.p8" - fi - - - name: Build, sign and verify - env: - PROGRAMA_IOS_DIST_CERT_P12: ${{ runner.temp }}/signing/dist.p12 - PROGRAMA_IOS_DIST_CERT_PASSWORD: ${{ secrets.APPLE_IOS_DIST_CERT_PASSWORD }} - PROGRAMA_IOS_APP_PROFILE: ${{ runner.temp }}/signing/app.mobileprovision - PROGRAMA_IOS_WIDGET_PROFILE: ${{ runner.temp }}/signing/widget.mobileprovision - PROGRAMA_IOS_TEAM_ID: ZNHHMX2RP6 - PROGRAMA_ASC_KEY_ID: ${{ secrets.APPSTORE_CONNECT_KEY_ID }} - PROGRAMA_ASC_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_ISSUER_ID }} - PROGRAMA_ASC_KEY_P8: ${{ runner.temp }}/signing/asc.p8 - # A push to main always uploads; workflow_dispatch honours its input and - # defaults to verify-only. - PROGRAMA_IOS_UPLOAD: ${{ github.event_name == 'push' && '1' || (inputs.upload && '1' || '0') }} - run: | - set -euo pipefail - # Monotonic and unique per run, matching the macOS release lane. App Store - # Connect rejects a reused CFBundleVersion, and the committed value in - # project.yml is only a local-dev default. - RUN_ATTEMPT="$(printf '%02d' "${GITHUB_RUN_ATTEMPT:-1}")" - export PROGRAMA_IOS_BUILD_NUMBER="${GITHUB_RUN_ID}${RUN_ATTEMPT}" - echo "Build number: $PROGRAMA_IOS_BUILD_NUMBER" - chmod +x scripts/build-ios-testflight.sh - ./scripts/build-ios-testflight.sh - - - name: Upload ipa artifact - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: programa-ios-${{ github.run_id }} - path: .ios-build/export/*.ipa - if-no-files-found: warn - retention-days: 14 - - - name: Clean up signing material - if: always() - run: | - # build-ios-testflight.sh restores the prior keychain/profile/API-key - # state on EXIT. This only removes the workflow-owned decoded inputs. - rm -rf "$RUNNER_TEMP/signing" || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b8bf85..4759addf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -143,7 +143,6 @@ jobs: PAYLOAD_RELEASE_TAG="${RELEASE_TAG}" IS_AUTO_SHIP="false" EFFECTIVE_MARKETING_VERSION="${MARKETING_VERSION}" - REMOTE_DAEMON_ASSET_SUFFIX="-${EFFECTIVE_BUILD}" else # Auto-ship from main (workflow_run) or dry-run (workflow_dispatch): keep the # user-facing latest surface on the reused mutable `rolling` release while every @@ -160,7 +159,6 @@ jobs: # (scripts/bump-version.sh); the patch sequence continues across them, # which keeps versions strictly increasing in Sparkle-visible order. EFFECTIVE_MARKETING_VERSION="${MARKETING_VERSION%.*}.${GITHUB_RUN_NUMBER}" - REMOTE_DAEMON_ASSET_SUFFIX="-${EFFECTIVE_BUILD}" fi echo "MARKETING_VERSION=${MARKETING_VERSION}" >> "$GITHUB_ENV" @@ -169,7 +167,6 @@ jobs: echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV" echo "PAYLOAD_RELEASE_TAG=${PAYLOAD_RELEASE_TAG}" >> "$GITHUB_ENV" echo "IS_AUTO_SHIP=${IS_AUTO_SHIP}" >> "$GITHUB_ENV" - echo "REMOTE_DAEMON_ASSET_SUFFIX=${REMOTE_DAEMON_ASSET_SUFFIX}" >> "$GITHUB_ENV" echo "effective_build=${EFFECTIVE_BUILD}" >> "$GITHUB_OUTPUT" echo "Marketing version: ${MARKETING_VERSION} (effective: ${EFFECTIVE_MARKETING_VERSION})" @@ -317,12 +314,6 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- - - name: Setup Go - if: steps.milestone_artifact.outputs.reuse != 'true' - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version-file: daemon/remote/go.mod - - name: Derive Sparkle public key from private key if: steps.milestone_artifact.outputs.reuse != 'true' env: @@ -381,28 +372,10 @@ jobs: # No-op for tag releases (both values already match the committed ones). # For auto-ship runs this stamps the monotonic run-derived build number AND # the distinct per-ship marketing version (major.minor.run_number) without - # committing anything back to the repo. Must run BEFORE the remote-daemon - # manifest step below, which reads CFBundleShortVersionString. + # committing anything back to the repo. /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${EFFECTIVE_BUILD}" "$APP_PLIST" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${EFFECTIVE_MARKETING_VERSION}" "$APP_PLIST" - - name: Build remote daemon release assets and inject manifest - if: steps.milestone_artifact.outputs.reuse != 'true' - run: | - set -euo pipefail - APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" - APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") - ./scripts/build_remote_daemon_release_assets.sh \ - --version "$APP_VERSION" \ - --release-tag "$PAYLOAD_RELEASE_TAG" \ - --repo "darkroomengineering/programa" \ - --output-dir "remote-daemon-assets" \ - --asset-suffix "${REMOTE_DAEMON_ASSET_SUFFIX#-}" - MANIFEST_PATH="remote-daemon-assets/programad-remote-manifest${REMOTE_DAEMON_ASSET_SUFFIX}.json" - MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' "$MANIFEST_PATH")" - plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true - plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" - - name: Run CLI version memory guard regression if: steps.milestone_artifact.outputs.reuse != 'true' run: | @@ -614,12 +587,6 @@ jobs: cp "programa-dSYMs-${EFFECTIVE_BUILD}.zip" "$MILESTONE_PAYLOAD_DIR/" cp "programa-macos-${EFFECTIVE_BUILD}.dmg" "$MILESTONE_PAYLOAD_DIR/" cp programa-macos.dmg "$MILESTONE_PAYLOAD_DIR/" - cp "remote-daemon-assets/programad-remote-checksums-${EFFECTIVE_BUILD}.txt" "$MILESTONE_PAYLOAD_DIR/" - cp "remote-daemon-assets/programad-remote-darwin-amd64-${EFFECTIVE_BUILD}" "$MILESTONE_PAYLOAD_DIR/" - cp "remote-daemon-assets/programad-remote-darwin-arm64-${EFFECTIVE_BUILD}" "$MILESTONE_PAYLOAD_DIR/" - cp "remote-daemon-assets/programad-remote-linux-amd64-${EFFECTIVE_BUILD}" "$MILESTONE_PAYLOAD_DIR/" - cp "remote-daemon-assets/programad-remote-linux-arm64-${EFFECTIVE_BUILD}" "$MILESTONE_PAYLOAD_DIR/" - cp "remote-daemon-assets/programad-remote-manifest-${EFFECTIVE_BUILD}.json" "$MILESTONE_PAYLOAD_DIR/" node - "$MILESTONE_PAYLOAD_DIR" "$EFFECTIVE_BUILD" <<'NODE' "use strict"; const path = require("node:path"); @@ -645,12 +612,6 @@ jobs: subject-path: | programa-macos-${{ env.EFFECTIVE_BUILD }}.dmg programa-dSYMs-${{ env.EFFECTIVE_BUILD }}.zip - remote-daemon-assets/programad-remote-darwin-arm64${{ env.REMOTE_DAEMON_ASSET_SUFFIX }} - remote-daemon-assets/programad-remote-darwin-amd64${{ env.REMOTE_DAEMON_ASSET_SUFFIX }} - remote-daemon-assets/programad-remote-linux-arm64${{ env.REMOTE_DAEMON_ASSET_SUFFIX }} - remote-daemon-assets/programad-remote-linux-amd64${{ env.REMOTE_DAEMON_ASSET_SUFFIX }} - remote-daemon-assets/programad-remote-checksums${{ env.REMOTE_DAEMON_ASSET_SUFFIX }}.txt - remote-daemon-assets/programad-remote-manifest${{ env.REMOTE_DAEMON_ASSET_SUFFIX }}.json appcast.xml programa-macos.dmg @@ -682,12 +643,6 @@ jobs: --seal-output "$CANDIDATE_SEAL" \ --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programa-macos-${EFFECTIVE_BUILD}.dmg" \ --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programa-dSYMs-${EFFECTIVE_BUILD}.zip" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-darwin-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-darwin-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-linux-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-linux-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-checksums-${EFFECTIVE_BUILD}.txt" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-manifest-${EFFECTIVE_BUILD}.json" \ --asset-role "appcast=${MILESTONE_PAYLOAD_DIR}/appcast.xml" \ --asset-role "stable-alias=${MILESTONE_PAYLOAD_DIR}/programa-macos.dmg" echo "CANDIDATE_SEAL=${CANDIDATE_SEAL}" >> "$GITHUB_ENV" @@ -722,12 +677,6 @@ jobs: --seal-output "$CANDIDATE_SEAL" \ --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programa-macos-${EFFECTIVE_BUILD}.dmg" \ --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programa-dSYMs-${EFFECTIVE_BUILD}.zip" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-darwin-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-darwin-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-linux-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-linux-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-checksums-${EFFECTIVE_BUILD}.txt" \ - --asset-role "immutable=${MILESTONE_PAYLOAD_DIR}/programad-remote-manifest-${EFFECTIVE_BUILD}.json" \ --asset-role "appcast=${MILESTONE_PAYLOAD_DIR}/appcast.xml" \ --asset-role "stable-alias=${MILESTONE_PAYLOAD_DIR}/programa-macos.dmg" @@ -741,7 +690,6 @@ jobs: programa-macos-*.dmg programa-dSYMs-*.zip appcast.xml - remote-daemon-assets/programad-remote-* if-no-files-found: error - name: Upload milestone release assets @@ -805,12 +753,6 @@ jobs: --version "$EFFECTIVE_MARKETING_VERSION" \ --asset-role "immutable=programa-macos-${EFFECTIVE_BUILD}.dmg" \ --asset-role "immutable=$DSYM_ZIP" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-darwin-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-darwin-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-linux-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-linux-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-checksums-${EFFECTIVE_BUILD}.txt" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-manifest-${EFFECTIVE_BUILD}.json" \ --asset-role "appcast=appcast.xml" \ --asset-role "stable-alias=programa-macos.dmg" @@ -838,12 +780,6 @@ jobs: --version "$EFFECTIVE_MARKETING_VERSION" \ --asset-role "immutable=programa-macos-${EFFECTIVE_BUILD}.dmg" \ --asset-role "immutable=$DSYM_ZIP" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-darwin-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-darwin-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-linux-arm64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-linux-amd64-${EFFECTIVE_BUILD}" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-checksums-${EFFECTIVE_BUILD}.txt" \ - --asset-role "immutable=remote-daemon-assets/programad-remote-manifest-${EFFECTIVE_BUILD}.json" \ --asset-role "appcast=appcast.xml" \ --asset-role "stable-alias=programa-macos.dmg" @@ -931,12 +867,6 @@ jobs: const expectedNames = new Set([ `programa-macos-${build}.dmg`, `programa-dSYMs-${build}.zip`, - `programad-remote-darwin-arm64-${build}`, - `programad-remote-darwin-amd64-${build}`, - `programad-remote-linux-arm64-${build}`, - `programad-remote-linux-amd64-${build}`, - `programad-remote-checksums-${build}.txt`, - `programad-remote-manifest-${build}.json`, 'appcast.xml', 'programa-macos.dmg', ]); diff --git a/CHANGELOG.md b/CHANGELOG.md index 693aee56..260750dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,18 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ## [Unreleased] +### Removed +- SSH remote workspaces and the `programa ssh` command. The remote daemon, its release assets, and the browser proxy routing that went with it are gone; workspaces are local only. See `docs/removed/ssh-remote-workspaces.md`. +- The mobile bridge and the iOS companion app, including the Phone tab in Settings and the iroh transport that shipped inside the app. +- The browser data import wizard, browser extensions, and the React Grab overlay. Design Mode stays and is reachable through the browser automation API. +- AppleScript support, the inline VS Code panel, and custom notification sound files. The system sound picker and the plain "Open in VS Code" menu item remain. +- Every removal is recorded under `docs/removed/`, with the commit to restore from and what we learned while it was in the app. + ### Added - Agent Overview now gives one place to see every Programa workspace, worktree, helper, and terminal. Claude helpers appear and finish automatically; terminal output stays hidden until requested. Users can open terminals, send messages, stop work, review changes, and copy output. New helpers open as nested workspaces in the same folder, or use a separate Git worktree only when they explicitly need one. - A local Git workspace can now become a persistent worktree folder from its right-click menu. Worktree workspaces created from that menu nest beneath it, can be collapsed, and restore in the same hierarchy without changing or deleting their Git branches. - Workspace colors are now remembered by local folder and automatically reused when that folder opens in a new workspace. +- New setting, off by default: open a browser split beside the terminal whenever a new agent workspace is created (⌘⇧C, `programa` helper agents, and `race`). Also `automation.openBrowserWithAgentSplits` in settings.json. ### Fixed - Remote and local CLI clients now share the v2 JSON-RPC and `programa-relay-auth` contracts, password-protected sockets work through MCP, and remote bootstrap files, tmux wait signals, relay diagnostics, and downloaded daemon artifacts have bounded ownership and lifetime. diff --git a/CLAUDE.md b/CLAUDE.md index a23665e0..189f7d66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,13 +68,6 @@ When rebuilding GhosttyKit.xcframework, always use Release optimizations: cd ghostty && zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast ``` -The remote daemon (`programad-remote`) is Go, lives at `daemon/remote/`, and is only -spawned per SSH connection — it is not resident locally. To rebuild its release assets: - -```bash -./scripts/build_remote_daemon_release_assets.sh -``` - `reload` = build the Debug app (tag required). Pass `--launch` to also kill existing and open: ```bash diff --git a/CLI-MCP/MCPSocketBridge.swift b/CLI-MCP/MCPSocketBridge.swift index 11be7f07..118746ba 100644 --- a/CLI-MCP/MCPSocketBridge.swift +++ b/CLI-MCP/MCPSocketBridge.swift @@ -46,9 +46,8 @@ extension MCPSocketBridgeError: CustomStringConvertible { /// Mirrors `SocketClient`/`sendV2` in `CLI/programa.swift:386-1039` closely /// enough that timeout behavior, the `ERROR:`-prefix pre-JSON case, and the /// idle-gap multiline read stay consistent between the CLI and the MCP -/// sidecar -- see docs/plans/mcp-server.md §1.2-§1.4. Deliberately does not -/// carry over the CLI's relay-endpoint (SSH `host:port`) support: the MCP -/// sidecar only ever talks to a local Unix domain socket. +/// sidecar -- see docs/plans/mcp-server.md §1.2-§1.4. The MCP sidecar only +/// ever talks to a local Unix domain socket. /// /// Connects fresh per call and closes afterward -- matches the CLI's /// per-invocation connection lifecycle (no pooling / keep-alive), which is diff --git a/CLI-MCP/ToolCatalog.swift b/CLI-MCP/ToolCatalog.swift index 5b2b1579..32972d8a 100644 --- a/CLI-MCP/ToolCatalog.swift +++ b/CLI-MCP/ToolCatalog.swift @@ -172,7 +172,6 @@ enum ToolCatalog { /// app chrome, not terminal control. /// - `app.*`: app-wide test-harness side effects. /// - `agent.detection.*`: deferred for MVP scope only (not a risk exclusion). - /// - `workspace.remote.*`: the SSH remote-daemon control plane, explicitly out of scope. /// - `surface.drag_to_split`: UI gesture simulation. static let all: [ProgramaTool] = SystemTools.tools diff --git a/CLI/CLI+SSH.swift b/CLI/CLI+SSH.swift deleted file mode 100644 index 5ffc62f1..00000000 --- a/CLI/CLI+SSH.swift +++ /dev/null @@ -1,1404 +0,0 @@ -import Foundation -import CryptoKit -import Darwin -#if canImport(LocalAuthentication) -import LocalAuthentication -#endif -#if canImport(Security) -import Security -#endif - -extension ProgramaCLI { - private func generateRemoteRelayPort() -> Int { - // Random port in the ephemeral range (49152-65535) - Int.random(in: 49152...65535) - } - - private func randomHex(byteCount: Int) throws -> String { - var bytes = [UInt8](repeating: 0, count: byteCount) - let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - guard status == errSecSuccess else { - throw CLIError(message: "failed to generate SSH relay credential") - } - return bytes.map { String(format: "%02x", $0) }.joined() - } - - func runSSH( - commandArgs: [String], - client: SocketClient, - jsonOutput: Bool, - idFormat: CLIIDFormat - ) throws { - let sshStartedAt = Date() - // Use the socket path from this invocation (supports --socket overrides). - let localSocketPath = client.socketPath - let remoteRelayPort = generateRemoteRelayPort() - let relayID = UUID().uuidString.lowercased() - let relayToken = try randomHex(byteCount: 32) - let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) - func logSSHTiming(_ stage: String, extra: String = "") { - let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000) - let suffix = extra.isEmpty ? "" : " \(extra)" - cliDebugLog( - "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + - "stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)" - ) - } - - logSSHTiming("parsed") - let terminfoSource = localXtermGhosttyTerminfoSource() - cliDebugLog( - "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + - "stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " + - "source=\(terminfoSource == nil ? 0 : 1)" - ) - let shellFeaturesValue = scopedGhosttyShellFeaturesValue() - let remoteSSHOptions = effectiveSSHOptions( - sshOptions.sshOptions, - remoteRelayPort: sshOptions.remoteRelayPort - ) - let initialSSHCommand = buildSSHCommandText(sshOptions) - let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty - ? buildInteractiveRemoteShellScript( - remoteRelayPort: sshOptions.remoteRelayPort, - shellFeatures: shellFeaturesValue, - terminfoSource: terminfoSource - ) - : nil - let remoteTerminalSSHCommand = buildSSHCommandText( - sshOptions, - remoteBootstrapScript: remoteTerminalBootstrapScript - ) - let deferredRemoteReconnectToken = UUID().uuidString.lowercased() - let deferredRemoteReconnectCommand = deferredRemoteReconnectLocalCommand( - in: remoteSSHOptions, - localCLIPath: resolvedExecutableURL()?.path, - foregroundAuthToken: deferredRemoteReconnectToken - ) - let configuredForegroundAuthToken = deferredRemoteReconnectCommand == nil ? nil : deferredRemoteReconnectToken - let startupInitialSSHCommand = buildSSHCommandText( - sshOptions, - localCommand: deferredRemoteReconnectCommand - ) - let startupRemoteTerminalSSHCommand = buildSSHCommandText( - sshOptions, - remoteBootstrapScript: remoteTerminalBootstrapScript, - localCommand: deferredRemoteReconnectCommand - ) - let initialSSHStartupCommand: String - let remoteTerminalSSHStartupCommand: String - if let remoteTerminalBootstrapScript, !remoteTerminalBootstrapScript.isEmpty { - let bootstrapSSHStartupCommand = try buildBootstrapSSHStartupCommand( - options: sshOptions, - remoteBootstrapScript: remoteTerminalBootstrapScript, - shellFeatures: shellFeaturesValue, - remoteRelayPort: sshOptions.remoteRelayPort, - localCommand: deferredRemoteReconnectCommand - ) - initialSSHStartupCommand = bootstrapSSHStartupCommand - remoteTerminalSSHStartupCommand = bootstrapSSHStartupCommand - } else { - initialSSHStartupCommand = try buildSSHStartupCommand( - sshCommand: startupInitialSSHCommand, - shellFeatures: "", - remoteRelayPort: sshOptions.remoteRelayPort - ) - remoteTerminalSSHStartupCommand = try buildSSHStartupCommand( - sshCommand: startupRemoteTerminalSSHCommand, - shellFeatures: shellFeaturesValue, - remoteRelayPort: sshOptions.remoteRelayPort - ) - } - cliDebugLog( - "cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " + - "relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " + - "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + - "workspaceName=\(sshOptions.workspaceName?.replacingOccurrences(of: " ", with: "_") ?? "nil") " + - "extraArgs=\(sshOptions.extraArguments.count)" - ) - - let workspaceCreateParams: [String: Any] = [ - "initial_command": initialSSHStartupCommand, - "apply_remembered_folder_color": false, - ] - - let workspaceCreateStartedAt = Date() - let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) - guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { - throw CLIError(message: "workspace.create did not return workspace_id") - } - let workspaceWindowId = (workspaceCreate["window_id"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - cliDebugLog( - "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + - "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" - ) - cliDebugLog( - "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + - "workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))" - ) - let configuredPayload: [String: Any] - do { - if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), - !workspaceName.isEmpty { - _ = try client.sendV2(method: "workspace.rename", params: [ - "workspace_id": workspaceId, - "title": workspaceName, - ]) - } - - var configureParams: [String: Any] = [ - "workspace_id": workspaceId, - "destination": sshOptions.destination, - "auto_connect": deferredRemoteReconnectCommand == nil, - ] - if let configuredForegroundAuthToken { - configureParams["foreground_auth_token"] = configuredForegroundAuthToken - } - if let port = sshOptions.port { - configureParams["port"] = port - } - if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) { - configureParams["identity_file"] = identityFile - } - if !remoteSSHOptions.isEmpty { - configureParams["ssh_options"] = remoteSSHOptions - } - if sshOptions.remoteRelayPort > 0 { - configureParams["relay_port"] = sshOptions.remoteRelayPort - configureParams["relay_id"] = relayID - configureParams["relay_token"] = relayToken - configureParams["local_socket_path"] = sshOptions.localSocketPath - } - configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand - - cliDebugLog( - "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + - "target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + - "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + - "deferredReconnect=\(deferredRemoteReconnectCommand == nil ? 0 : 1) " + - "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" - ) - let configureStartedAt = Date() - configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) - var selectParams: [String: Any] = ["workspace_id": workspaceId] - if let workspaceWindowId, !workspaceWindowId.isEmpty { - selectParams["window_id"] = workspaceWindowId - } - // `programa ssh` is an explicit "open this remote workspace now" action, - // so we intentionally select the newly created workspace after wiring - // up the remote connection — unless --no-focus is passed. - if !sshOptions.noFocus { - _ = try client.sendV2(method: "workspace.select", params: selectParams) - } - let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown" - cliDebugLog( - "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" - ) - cliDebugLog( - "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + - "workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))" - ) - } catch { - cliDebugLog( - "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" - ) - do { - _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) - } catch { - let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n" - FileHandle.standardError.write(Data(warning.utf8)) - } - throw error - } - - var payload = configuredPayload - - payload["ssh_command"] = initialSSHCommand - payload["ssh_startup_command"] = initialSSHStartupCommand - payload["ssh_terminal_command"] = remoteTerminalSSHCommand - payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand - payload["ssh_env_overrides"] = [ - "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, - ] - payload["remote_relay_port"] = remoteRelayPort - logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) - } else { - let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? workspaceId - let remote = payload["remote"] as? [String: Any] - let state = (remote?["state"] as? String) ?? "unknown" - print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state)") - } - } - - private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { - var destination: String? - var port: Int? - var identityFile: String? - var workspaceName: String? - var noFocus = false - var sshOptions: [String] = [] - var extraArguments: [String] = [] - - var passthrough = false - var index = 0 - while index < commandArgs.count { - let arg = commandArgs[index] - if passthrough { - extraArguments.append(arg) - index += 1 - continue - } - - switch arg { - case "--": - passthrough = true - index += 1 - case "--port": - guard index + 1 < commandArgs.count else { - throw CLIError(message: "ssh: --port requires a value") - } - guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else { - throw CLIError(message: "ssh: --port must be 1-65535") - } - port = parsed - index += 2 - case "--identity": - guard index + 1 < commandArgs.count else { - throw CLIError(message: "ssh: --identity requires a path") - } - identityFile = commandArgs[index + 1] - index += 2 - case "--name": - guard index + 1 < commandArgs.count else { - throw CLIError(message: "ssh: --name requires a workspace title") - } - workspaceName = commandArgs[index + 1] - index += 2 - case "--no-focus": - noFocus = true - index += 1 - case "--ssh-option": - guard index + 1 < commandArgs.count else { - throw CLIError(message: "ssh: --ssh-option requires a value") - } - let value = commandArgs[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - sshOptions.append(value) - } - index += 2 - default: - if arg.hasPrefix("--") { - throw CLIError(message: "ssh: unknown flag '\(arg)'") - } - if destination == nil { - if arg.hasPrefix("-") { - throw CLIError( - message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." - ) - } - destination = arg - } else { - extraArguments.append(arg) - } - index += 1 - } - } - - guard let destination else { - throw CLIError(message: "ssh requires a destination (example: programa ssh user@host)") - } - - // #4948: accept bracketed IPv6 destinations (e.g. `[::1]`, `user@[2001:db8::1]:2222`). - // ssh needs the host unbracketed; an inline `:port` after the bracket maps to --port. - // Only bracketed forms are rewritten — plain `user@host` and bare IPv6 pass through. - let (resolvedDestination, inlinePort) = Self.normalizeSSHDestination(destination) - if let inlinePort, port == nil { - port = inlinePort - } - - return SSHCommandOptions( - destination: resolvedDestination, - port: port, - identityFile: identityFile, - workspaceName: workspaceName, - noFocus: noFocus, - sshOptions: sshOptions, - extraArguments: extraArguments, - localSocketPath: localSocketPath, - remoteRelayPort: remoteRelayPort - ) - } - - /// Syntax-only entrypoint used by the central registry before it opens or - /// focuses an app socket. Remote arguments after `--` remain passthrough. - func validateSSHCommandArguments(_ commandArgs: [String]) throws { - _ = try parseSSHCommandOptions(commandArgs) - } - - /// Normalizes an SSH destination: unwraps a bracketed IPv6 literal and extracts an - /// inline port. Returns `(destination, port?)`. Only bracketed forms are altered; - /// `user@host`, plain hostnames, and bare IPv6 (`2001:db8::1`) pass through unchanged. - static func normalizeSSHDestination(_ raw: String) -> (String, Int?) { - let userPrefix: String - let hostPart: String - if let atIdx = raw.firstIndex(of: "@") { - userPrefix = String(raw[...atIdx]) // includes the trailing "@" - hostPart = String(raw[raw.index(after: atIdx)...]) - } else { - userPrefix = "" - hostPart = raw - } - guard hostPart.hasPrefix("["), let closeIdx = hostPart.firstIndex(of: "]") else { - return (raw, nil) - } - let inner = String(hostPart[hostPart.index(after: hostPart.startIndex).. 0, parsed <= 65535 else { - return (raw, nil) // invalid inline port — leave unchanged so ssh surfaces a clear error - } - port = parsed - } else if !afterBracket.isEmpty { - return (raw, nil) // unexpected trailing content — don't touch - } - return (userPrefix + inner, port) - } - - func buildSSHCommandText( - _ options: SSHCommandOptions, - remoteBootstrapScript: String? = nil, - localCommand: String? = nil - ) -> String { - var parts = baseSSHArguments(options, localCommand: localCommand) - let trimmedRemoteBootstrap = remoteBootstrapScript? - .trimmingCharacters(in: .whitespacesAndNewlines) - - if options.extraArguments.isEmpty { - if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty { - let remoteCommand = sshPercentEscapedRemoteCommand( - encodedRemoteBootstrapCommand( - trimmedRemoteBootstrap, - remoteRelayPort: options.remoteRelayPort - ) - ) - parts += ["-o", "RemoteCommand=\(remoteCommand)"] - } - if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { - parts.append("-tt") - } - parts.append(options.destination) - } else { - parts.append(options.destination) - parts.append(contentsOf: options.extraArguments) - } - return parts.map(shellQuote).joined(separator: " ") - } - - func buildBootstrapSSHStartupCommand( - options: SSHCommandOptions, - remoteBootstrapScript: String, - shellFeatures: String, - remoteRelayPort: Int, - localCommand: String? = nil - ) throws -> String { - let commandSnippet = buildSSHBootstrapCommandSnippet( - options: options, - remoteBootstrapScript: remoteBootstrapScript, - localCommand: localCommand - ) - return try buildSSHStartupCommand( - sshCommand: commandSnippet, - shellFeatures: shellFeatures, - remoteRelayPort: remoteRelayPort, - isShellSnippet: true - ) - } - - private func buildSSHBootstrapCommandSnippet( - options: SSHCommandOptions, - remoteBootstrapScript: String, - localCommand: String? = nil - ) -> String { - let encodedBootstrapScript = Data(remoteBootstrapScript.utf8).base64EncodedString() - let installSSHPrefix = baseSSHArguments(options, localCommand: localCommand).map(shellQuote).joined(separator: " ") - let sessionSSHPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ") - let remoteCommandTemplate = sshPercentEscapedRemoteCommand( - stagedRemoteBootstrapCommandShell( - remoteRelayPort: options.remoteRelayPort - ) - ) - let remoteBootstrapInstallCommand = "/bin/sh -c " + shellQuote( - remoteBootstrapInstallShell(remoteRelayPort: options.remoteRelayPort) - ) - var lines: [String] = [ - "programa_workspace_id=\"${PROGRAMA_WORKSPACE_ID:-}\"", - "programa_surface_id=\"${PROGRAMA_SURFACE_ID:-}\"", - "programa_remote_bootstrap_b64=\(shellQuote(encodedBootstrapScript))", - "programa_remote_bootstrap=\"$(printf %s \"$programa_remote_bootstrap_b64\" | base64 -d 2>/dev/null || printf %s \"$programa_remote_bootstrap_b64\" | base64 -D 2>/dev/null)\"", - "programa_remote_bootstrap=\"$(printf '%s' \"$programa_remote_bootstrap\" | sed \"s/__PROGRAMA_WORKSPACE_ID__/$programa_workspace_id/g; s/__PROGRAMA_SURFACE_ID__/$programa_surface_id/g\")\"", - "if ! printf '%s' \"$programa_remote_bootstrap\" | command \(installSSHPrefix) -T \(shellQuote(options.destination)) \(shellQuote(remoteBootstrapInstallCommand)); then", - " exit 1", - "fi", - "programa_remote_command_template=\(shellQuote(remoteCommandTemplate))", - "programa_remote_command=\"$(printf '%s' \"$programa_remote_command_template\" | sed \"s/__PROGRAMA_WORKSPACE_ID__/$programa_workspace_id/g; s/__PROGRAMA_SURFACE_ID__/$programa_surface_id/g\")\"", - ] - - var sshInvocation = "command \(sessionSSHPrefix) -o \"RemoteCommand=$programa_remote_command\"" - if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { - sshInvocation += " -tt" - } - sshInvocation += " " + shellQuote(options.destination) - lines.append(sshInvocation) - return lines.joined(separator: "\n") - } - - private func stagedRemoteBootstrapCommandShell( - remoteRelayPort: Int - ) -> String { - var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: true) - lines.append("/bin/sh \"$HOME/.programa/relay/\(remoteRelayPort).bootstrap.sh\"") - return lines.joined(separator: "\n") - } - - private func remoteBootstrapInstallShell(remoteRelayPort: Int) -> String { - [ - "set -eu", - "umask 077", - "programa_bootstrap_path=\"$HOME/.programa/relay/\(remoteRelayPort).bootstrap.sh\"", - "mkdir -p \"$HOME/.programa/relay\"", - "cat > \"$programa_bootstrap_path\"", - "chmod 700 \"$programa_bootstrap_path\" >/dev/null 2>&1 || true", - ].joined(separator: "\n") - } - - private func runtimeEncodedRemoteBootstrapCommandShell( - base64Placeholder: String, - remoteRelayPort: Int - ) -> String { - var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: false) - lines += [ - "programa_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", - "(printf %s '\(base64Placeholder)' | base64 -d 2>/dev/null || printf %s '\(base64Placeholder)' | base64 -D 2>/dev/null) > \"$programa_tmp\" || { rm -f \"$programa_tmp\"; exit 1; }", - "chmod 700 \"$programa_tmp\" >/dev/null 2>&1 || true", - "/bin/sh \"$programa_tmp\"", - "programa_status=$?", - "rm -f \"$programa_tmp\"", - "exit $programa_status", - ] - return lines.joined(separator: "\n") - } - - private func remoteBootstrapTTYCaptureLines( - remoteRelayPort: Int, - includeRelayRPC: Bool - ) -> [String] { - guard remoteRelayPort > 0 else { return [] } - - var lines: [String] = [ - "programa_bootstrap_tty=\"$(tty 2>/dev/null || true)\"", - "programa_bootstrap_tty=\"${programa_bootstrap_tty##*/}\"", - "if [ -n \"$programa_bootstrap_tty\" ] && [ \"$programa_bootstrap_tty\" != \"not a tty\" ]; then", - " mkdir -p \"$HOME/.programa/relay\" >/dev/null 2>&1 || true", - " printf '%s' \"$programa_bootstrap_tty\" > \"$HOME/.programa/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", - " export PROGRAMA_BOOTSTRAP_TTY=\"$programa_bootstrap_tty\"", - ] - - if includeRelayRPC { - lines += [ - " programa_relay_cli=\"$HOME/.programa/bin/programa\"", - " if [ ! -x \"$programa_relay_cli\" ]; then programa_relay_cli=\"$(command -v programa 2>/dev/null || true)\"; fi", - " if [ -n \"$programa_relay_cli\" ]; then", - " programa_relay_report_tty='{\"workspace_id\":\"__PROGRAMA_WORKSPACE_ID__\",\"tty_name\":\"'$programa_bootstrap_tty'\"}'", - " programa_relay_ports_kick='{\"workspace_id\":\"__PROGRAMA_WORKSPACE_ID__\",\"reason\":\"command\"}'", - " if [ -n \"__PROGRAMA_SURFACE_ID__\" ]; then", - " programa_relay_report_tty='{\"workspace_id\":\"__PROGRAMA_WORKSPACE_ID__\",\"surface_id\":\"__PROGRAMA_SURFACE_ID__\",\"tty_name\":\"'$programa_bootstrap_tty'\"}'", - " programa_relay_ports_kick='{\"workspace_id\":\"__PROGRAMA_WORKSPACE_ID__\",\"surface_id\":\"__PROGRAMA_SURFACE_ID__\",\"reason\":\"command\"}'", - " fi", - " PROGRAMA_SOCKET_PATH=\"127.0.0.1:\(remoteRelayPort)\" PROGRAMA_SOCKET=\"127.0.0.1:\(remoteRelayPort)\" \"$programa_relay_cli\" rpc surface.report_tty \"$programa_relay_report_tty\" >/dev/null 2>&1 || true", - " PROGRAMA_SOCKET_PATH=\"127.0.0.1:\(remoteRelayPort)\" PROGRAMA_SOCKET=\"127.0.0.1:\(remoteRelayPort)\" \"$programa_relay_cli\" rpc surface.ports_kick \"$programa_relay_ports_kick\" >/dev/null 2>&1 || true", - " unset programa_relay_cli programa_relay_report_tty programa_relay_ports_kick", - " fi", - ] - } - - lines.append("fi") - return lines - } - - private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { - var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort) - if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") { - merged.append("StrictHostKeyChecking=accept-new") - } - return merged - } - - func buildInteractiveRemoteShellScript( - remoteRelayPort: Int, - shellFeatures: String, - terminfoSource: String? = nil - ) -> String { - let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource) - let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) - let shellStateDir = shellStateDirForRemoteRelayPort(remoteRelayPort) - let remoteCallerExportLines = [ - "if [ -n '__PROGRAMA_WORKSPACE_ID__' ]; then export PROGRAMA_WORKSPACE_ID='__PROGRAMA_WORKSPACE_ID__'; fi", - "if [ -n '__PROGRAMA_WORKSPACE_ID__' ]; then export PROGRAMA_TAB_ID='__PROGRAMA_WORKSPACE_ID__'; fi", - "if [ -n '__PROGRAMA_SURFACE_ID__' ]; then export PROGRAMA_SURFACE_ID='__PROGRAMA_SURFACE_ID__'; export PROGRAMA_PANEL_ID='__PROGRAMA_SURFACE_ID__'; fi", - ] - let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil - var commonShellExportLines = remoteTerminalLines - commonShellExportLines.append(contentsOf: remoteEnvExportLines) - commonShellExportLines.append("export PATH=\"$HOME/.programa/bin:$PATH\"") - commonShellExportLines.append("export PROGRAMA_BUNDLED_CLI_PATH=\"$HOME/.programa/bin/programa\"") - commonShellExportLines.append("export PROGRAMA_SHELL_INTEGRATION_DIR=\"\(shellStateDir)\"") - if let relaySocket { - commonShellExportLines.append("export PROGRAMA_SOCKET_PATH=\(relaySocket)") - commonShellExportLines.append("export PROGRAMA_SOCKET=\(relaySocket)") - } - commonShellExportLines.append(contentsOf: remoteCallerExportLines) - commonShellExportLines.append(contentsOf: [ - "hash -r >/dev/null 2>&1 || true", - "rehash >/dev/null 2>&1 || true", - ]) - var zshShellLines = commonShellExportLines - zshShellLines.append( - #"if [ "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${PROGRAMA_SHELL_INTEGRATION_DIR}/programa-zsh-integration.zsh" ]; then . "${PROGRAMA_SHELL_INTEGRATION_DIR}/programa-zsh-integration.zsh"; fi"# - ) - var bashShellLines = commonShellExportLines - bashShellLines.append( - #"if [ "${PROGRAMA_SHELL_INTEGRATION:-1}" != "0" ] && [ -r "${PROGRAMA_SHELL_INTEGRATION_DIR}/programa-bash-integration.bash" ]; then . "${PROGRAMA_SHELL_INTEGRATION_DIR}/programa-bash-integration.bash"; fi"# - ) - let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) - let zshEnvLines = zshBootstrap.zshEnvLines - let zshProfileLines = zshBootstrap.zshProfileLines - let zshRCLines = zshBootstrap.zshRCLines(commonShellLines: zshShellLines) - let zshLoginLines = zshBootstrap.zshLoginLines - let bundledZshIntegration = bundledShellIntegrationScript(named: "programa-zsh-integration.zsh") - let bundledBashIntegration = bundledShellIntegrationScript(named: "programa-bash-integration.bash") - let bashRCLines = [ - "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", - "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", - ] + bashShellLines - let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) - - var outerLines: [String] = [ - "mkdir -p \"$HOME/.programa/relay\"", - "programa_shell_dir=\"\(shellStateDir)\"", - "mkdir -p \"$programa_shell_dir\"", - ] - if let bundledZshIntegration { - outerLines += [ - "cat > \"$programa_shell_dir/programa-zsh-integration.zsh\" <<'CMUXCMUXZSH'", - bundledZshIntegration, - "CMUXCMUXZSH", - ] - } - if let bundledBashIntegration { - outerLines += [ - "cat > \"$programa_shell_dir/programa-bash-integration.bash\" <<'CMUXCMUXBASH'", - bundledBashIntegration, - "CMUXCMUXBASH", - ] - } - outerLines.append(contentsOf: commonShellExportLines) - outerLines += [ - "PROGRAMA_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", - "case \"${PROGRAMA_LOGIN_SHELL##*/}\" in", - " zsh)", - " cat > \"$programa_shell_dir/.zshenv\" <<'CMUXZSHENV'", - ] - outerLines.append(contentsOf: zshEnvLines) - outerLines += [ - "CMUXZSHENV", - " cat > \"$programa_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", - ] - outerLines.append(contentsOf: zshProfileLines) - outerLines += [ - "CMUXZSHPROFILE", - " cat > \"$programa_shell_dir/.zshrc\" <<'CMUXZSHRC'", - ] - outerLines.append(contentsOf: zshRCLines) - outerLines += [ - "CMUXZSHRC", - " cat > \"$programa_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", - ] - outerLines.append(contentsOf: zshLoginLines) - outerLines += [ - "CMUXZSHLOGIN", - " chmod 600 \"$programa_shell_dir/.zshenv\" \"$programa_shell_dir/.zprofile\" \"$programa_shell_dir/.zshrc\" \"$programa_shell_dir/.zlogin\" >/dev/null 2>&1 || true", - ] - outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) - outerLines += [ - " export PROGRAMA_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", - " export ZDOTDIR=\"$programa_shell_dir\"", - " exec \"$PROGRAMA_LOGIN_SHELL\" -il", - " ;;", - " bash)", - " cat > \"$programa_shell_dir/.bashrc\" <<'CMUXBASHRC'", - ] - outerLines.append(contentsOf: bashRCLines) - outerLines += [ - "CMUXBASHRC", - " chmod 600 \"$programa_shell_dir/.bashrc\" >/dev/null 2>&1 || true", - ] - outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) - outerLines += [ - " exec \"$PROGRAMA_LOGIN_SHELL\" --rcfile \"$programa_shell_dir/.bashrc\" -i", - " ;;", - " *)", - ] - outerLines.append(contentsOf: commonShellExportLines) - outerLines.append(contentsOf: relayWarmupLines) - outerLines += [ - "exec \"$PROGRAMA_LOGIN_SHELL\" -i", - ";;", - "esac", - ] - - return outerLines.joined(separator: "\n") - } - - private func shellStateDirForRemoteRelayPort(_ remoteRelayPort: Int) -> String { - "$HOME/.programa/relay/\(max(remoteRelayPort, 0)).shell" - } - - private func bundledShellIntegrationScript(named fileName: String) -> String? { - let fileManager = FileManager.default - var candidates: [URL] = [] - - if let executableURL = resolvedExecutableURL() { - var current = executableURL.deletingLastPathComponent().standardizedFileURL - while true { - if current.lastPathComponent == "Contents" { - candidates.append( - current - .appendingPathComponent("Resources", isDirectory: true) - .appendingPathComponent("shell-integration", isDirectory: true) - .appendingPathComponent(fileName, isDirectory: false) - ) - } - - let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) - if fileManager.fileExists(atPath: projectMarker.path) { - candidates.append( - current - .appendingPathComponent("Resources", isDirectory: true) - .appendingPathComponent("shell-integration", isDirectory: true) - .appendingPathComponent(fileName, isDirectory: false) - ) - break - } - - guard let parent = parentSearchURL(for: current) else { - break - } - current = parent - } - } - - if let resourceURL = Bundle.main.resourceURL { - candidates.append( - resourceURL - .appendingPathComponent("shell-integration", isDirectory: true) - .appendingPathComponent(fileName, isDirectory: false) - ) - } - - for url in candidates { - guard fileManager.fileExists(atPath: url.path), - let data = try? Data(contentsOf: url), - let contents = String(data: data, encoding: .utf8) else { - continue - } - return contents - } - - return nil - } - - func buildInteractiveRemoteShellCommand( - remoteRelayPort: Int, - shellFeatures: String, - terminfoSource: String? = nil - ) -> String { - let script = buildInteractiveRemoteShellScript( - remoteRelayPort: remoteRelayPort, - shellFeatures: shellFeatures, - terminfoSource: terminfoSource - ) - return "/bin/sh -c \(shellQuote(script))" - } - - private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] { - var lines: [String] = [ - "programa_term='xterm-256color'", - "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", - " programa_term='xterm-ghostty'", - "fi", - "export TERM=\"$programa_term\"", - ] - guard let terminfoSource else { return lines } - let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedTerminfoSource.isEmpty else { return lines } - lines += [ - "if [ \"$programa_term\" != 'xterm-ghostty' ]; then", - " (", - " command -v tic >/dev/null 2>&1 || exit 0", - " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", - " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", - trimmedTerminfoSource, - "CMUXTERMINFO", - " ) >/dev/null 2>&1 &", - "fi", - ] - return lines - } - - private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { - let environment = ProcessInfo.processInfo.environment - let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" - let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" - let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) - ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) - ?? "" - let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) - - var exports: [String] = [ - "export COLORTERM=\(shellQuote(colorTerm))", - "export TERM_PROGRAM=\(shellQuote(termProgram))", - ] - if !termProgramVersion.isEmpty { - exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") - } - if !trimmedShellFeatures.isEmpty { - exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") - } - return exports - } - - private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { - guard remoteRelayPort > 0 else { - return [] - } - return [ - "programa_relay_cli=\"${PROGRAMA_BUNDLED_CLI_PATH:-$HOME/.programa/bin/programa}\"", - "if [ ! -x \"$programa_relay_cli\" ]; then programa_relay_cli=\"$(command -v programa 2>/dev/null || true)\"; fi", - "programa_relay_tty=\"${PROGRAMA_BOOTSTRAP_TTY:-}\"", - "if [ -z \"$programa_relay_tty\" ]; then programa_relay_tty=\"$(tty 2>/dev/null || true)\"; fi", - "programa_relay_tty=\"${programa_relay_tty##*/}\"", - "if [ -n \"$programa_relay_tty\" ] && [ \"$programa_relay_tty\" != \"not a tty\" ]; then", - " mkdir -p \"$HOME/.programa/relay\" >/dev/null 2>&1 || true", - " printf '%s' \"$programa_relay_tty\" > \"$HOME/.programa/relay/\(remoteRelayPort).tty\" 2>/dev/null || true", - "fi", - "if [ -n \"$programa_relay_cli\" ] && [ -n \"$PROGRAMA_WORKSPACE_ID\" ] && [ -n \"$programa_relay_tty\" ] && [ \"$programa_relay_tty\" != \"not a tty\" ]; then", - " programa_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$PROGRAMA_WORKSPACE_ID\\\",\\\"tty_name\\\":\\\"$programa_relay_tty\\\"}\"", - " programa_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$PROGRAMA_WORKSPACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", - " if [ -n \"$PROGRAMA_SURFACE_ID\" ]; then", - " programa_relay_report_tty=\"{\\\"workspace_id\\\":\\\"$PROGRAMA_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$PROGRAMA_SURFACE_ID\\\",\\\"tty_name\\\":\\\"$programa_relay_tty\\\"}\"", - " programa_relay_ports_kick=\"{\\\"workspace_id\\\":\\\"$PROGRAMA_WORKSPACE_ID\\\",\\\"surface_id\\\":\\\"$PROGRAMA_SURFACE_ID\\\",\\\"reason\\\":\\\"command\\\"}\"", - " fi", - " \"$programa_relay_cli\" rpc surface.report_tty \"$programa_relay_report_tty\" >/dev/null 2>&1 || true", - " \"$programa_relay_cli\" rpc surface.ports_kick \"$programa_relay_ports_kick\" >/dev/null 2>&1 || true", - "fi", - "unset PROGRAMA_BOOTSTRAP_TTY programa_relay_cli programa_relay_tty programa_relay_report_tty programa_relay_ports_kick", - ] - } - - private func baseSSHArguments(_ options: SSHCommandOptions, localCommand: String? = nil) -> [String] { - let effectiveSSHOptions = effectiveSSHOptions( - options.sshOptions, - remoteRelayPort: options.remoteRelayPort - ) - var parts: [String] = ["ssh"] - if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") { - parts += ["-o", "ConnectTimeout=6"] - } - if !hasSSHOptionKey(effectiveSSHOptions, key: "ServerAliveInterval") { - parts += ["-o", "ServerAliveInterval=20"] - } - if !hasSSHOptionKey(effectiveSSHOptions, key: "ServerAliveCountMax") { - parts += ["-o", "ServerAliveCountMax=2"] - } - if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") { - parts += ["-o", "SetEnv COLORTERM=truecolor"] - } - if !hasSSHOptionKey(effectiveSSHOptions, key: "SendEnv") { - parts += ["-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] - } - if let port = options.port { - parts += ["-p", String(port)] - } - if let identityFile = normalizedSSHIdentityPath(options.identityFile) { - parts += ["-i", identityFile] - } - for option in effectiveSSHOptions { - parts += ["-o", option] - } - if let localCommand, !localCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let escapedLocalCommand = localCommand.replacingOccurrences(of: "%", with: "%%") - parts += ["-o", "PermitLocalCommand=yes"] - parts += ["-o", "LocalCommand=\(escapedLocalCommand)"] - } - return parts - } - - private func localXtermGhosttyTerminfoSource() -> String? { - let result = runProcess( - executablePath: "/usr/bin/infocmp", - arguments: ["-0", "-x", "xterm-ghostty"] - ) - guard result.status == 0 else { return nil } - let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - return output.isEmpty ? nil : output - } - - private func sshOptionsWithControlSocketDefaults( - _ options: [String], - remoteRelayPort: Int? = nil - ) -> [String] { - var merged: [String] = [] - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - merged.append(trimmed) - } - if !hasSSHOptionKey(merged, key: "ControlMaster") { - merged.append("ControlMaster=auto") - } - if !hasSSHOptionKey(merged, key: "ControlPersist") { - merged.append("ControlPersist=600") - } - if !hasSSHOptionKey(merged, key: "ControlPath") { - merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))") - } - return merged - } - - private func scopedGhosttyShellFeaturesValue() -> String { - let rawExisting = ProcessInfo.processInfo.environment["GHOSTTY_SHELL_FEATURES"] ?? "" - var seen: Set = [] - var merged: [String] = [] - - for token in rawExisting.split(separator: ",") { - let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !feature.isEmpty else { continue } - if seen.insert(feature).inserted { - merged.append(feature) - } - } - - for required in ["ssh-env", "ssh-terminfo"] { - if seen.insert(required).inserted { - merged.append(required) - } - } - - return merged.joined(separator: ",") - } - - func encodedRemoteBootstrapCommand( - _ remoteBootstrapScript: String, - remoteRelayPort: Int - ) -> String { - let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString() - let encodedLiteral = shellQuote(encodedScript) - var lines = remoteBootstrapTTYCaptureLines(remoteRelayPort: remoteRelayPort, includeRelayRPC: false) - lines += [ - "programa_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", - "(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$programa_tmp\" || { rm -f \"$programa_tmp\"; exit 1; }", - "chmod 700 \"$programa_tmp\" >/dev/null 2>&1 || true", - "/bin/sh \"$programa_tmp\"", - "programa_status=$?", - "rm -f \"$programa_tmp\"", - "exit $programa_status", - ] - return lines.joined(separator: "\n") - } - - func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { - remoteCommand.replacingOccurrences(of: "%", with: "%%") - } - - func buildSSHStartupCommand( - sshCommand: String, - shellFeatures: String, - remoteRelayPort: Int, - isShellSnippet: Bool = false - ) throws -> String { - let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) - let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty - ? "" - : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" - let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) - var scriptLines: [String] = [] - if !shellFeaturesBootstrap.isEmpty { - scriptLines.append(shellFeaturesBootstrap) - } - scriptLines += [ - "PROGRAMA_SSH_SESSION_ENDED=0", - "programa_ssh_session_end() { if [ \"${PROGRAMA_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; PROGRAMA_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", - "trap 'programa_ssh_session_end' EXIT HUP INT TERM", - ] - if isShellSnippet { - scriptLines.append(sshCommand) - } else { - scriptLines.append("command \(sshCommand)") - } - scriptLines += [ - "programa_ssh_status=$?", - "trap - EXIT HUP INT TERM", - "programa_ssh_session_end", - "exit $programa_ssh_status", - ] - let script = scriptLines.joined(separator: "\n") - return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort) - } - - private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String { - let tempDir = FileManager.default.temporaryDirectory - let scriptURL = tempDir.appendingPathComponent( - "cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh" - ) - let script = "#!/bin/sh\n\(scriptBody)\n" - try script.write(to: scriptURL, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) - return shellQuote(scriptURL.path) - } - - private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { - [ - "if [ -n \"${PROGRAMA_BUNDLED_CLI_PATH:-}\" ]", - "&& [ -x \"${PROGRAMA_BUNDLED_CLI_PATH}\" ]", - "&& [ -n \"${PROGRAMA_SOCKET_PATH:-}\" ]", - "&& [ -n \"${PROGRAMA_WORKSPACE_ID:-}\" ]", - "&& [ -n \"${PROGRAMA_SURFACE_ID:-}\" ]; then", - "\"${PROGRAMA_BUNDLED_CLI_PATH}\" --socket \"${PROGRAMA_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${PROGRAMA_WORKSPACE_ID}\" --surface \"${PROGRAMA_SURFACE_ID}\" >/dev/null 2>&1 || true;", - "elif command -v cmux >/dev/null 2>&1", - "&& [ -n \"${PROGRAMA_WORKSPACE_ID:-}\" ]", - "&& [ -n \"${PROGRAMA_SURFACE_ID:-}\" ]; then", - "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${PROGRAMA_WORKSPACE_ID}\" --surface \"${PROGRAMA_SURFACE_ID}\" >/dev/null 2>&1 || true;", - "fi", - ].joined(separator: " ") - } - - func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { - guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), - let relayPort = Int(relayPortRaw), - relayPort > 0 else { - throw CLIError(message: "ssh-session-end requires --relay-port ") - } - let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] - let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] - guard let workspaceRaw, - let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), - !workspaceId.isEmpty else { - throw CLIError(message: "ssh-session-end requires --workspace or PROGRAMA_WORKSPACE_ID") - } - guard let surfaceRaw, - let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), - !surfaceId.isEmpty else { - throw CLIError(message: "ssh-session-end requires --surface or PROGRAMA_SURFACE_ID") - } - _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ - "workspace_id": workspaceId, - "surface_id": surfaceId, - "relay_port": relayPort, - ]) - } - - func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { - let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines) - let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines) - let info = resolvedVersionInfo() - let manifest = remoteDaemonManifest() - let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch) - let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch) - let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path) - let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil - let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch) - let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased()) - let releaseTag = manifest?.releaseTag ?? "unknown" - let assetName = entry?.assetName ?? "unknown" - let downloadURL = entry?.downloadURL ?? "unknown" - let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown" - let checksumsURL = manifest?.checksumsURL ?? "unknown" - let downloadCommand = "gh release download \(releaseTag) --repo darkroomengineering/programa --pattern \(assetName)" - let downloadChecksumsCommand = "gh release download \(releaseTag) --repo darkroomengineering/programa --pattern \(checksumsAssetName)" - let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing" - let signerWorkflow = "darkroomengineering/programa/.github/workflows/release.yml" - let verifyCommand = "gh attestation verify ./\(assetName) --repo darkroomengineering/programa --signer-workflow \(signerWorkflow)" - - let payload: [String: Any] = [ - "app_version": remoteDaemonVersionString(from: info), - "build": info["CFBundleVersion"] ?? NSNull(), - "commit": info["ProgramaCommit"] ?? NSNull(), - "manifest_present": manifest != nil, - "release_tag": releaseTag, - "release_url": manifest?.releaseURL ?? NSNull(), - "target_goos": platform.goOS, - "target_goarch": platform.goArch, - "asset_name": assetName, - "download_url": downloadURL, - "checksums_asset_name": checksumsAssetName, - "checksums_url": checksumsURL, - "expected_sha256": entry?.sha256 ?? NSNull(), - "cache_path": cacheURL.path, - "cache_exists": cacheExists, - "cache_sha256": cacheSHA ?? NSNull(), - "cache_verified": cacheVerified, - "dev_local_build_fallback": ProcessInfo.processInfo.environment["PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1", - "download_command": downloadCommand, - "download_checksums_command": downloadChecksumsCommand, - "checksum_verify_command": checksumVerifyCommand, - "attestation_verify_command": verifyCommand, - ] - - if jsonOutput { - print(jsonString(payload)) - return - } - - print("app version: \(payload["app_version"] as? String ?? "unknown")") - if let build = payload["build"] as? String { - print("build: \(build)") - } - if let commit = payload["commit"] as? String { - print("commit: \(commit)") - } - print("manifest: \(manifest != nil ? "present" : "missing")") - print("platform: \(platform.goOS)/\(platform.goArch)") - print("release: \(releaseTag)") - print("asset: \(assetName)") - print("download url: \(downloadURL)") - print("checksums asset: \(checksumsAssetName)") - print("checksums: \(checksumsURL)") - if let expectedSHA = entry?.sha256 { - print("expected sha256: \(expectedSHA)") - } - print("cache: \(cacheURL.path)") - print("cache exists: \(cacheExists ? "yes" : "no")") - if let cacheSHA { - print("cache sha256: \(cacheSHA)") - } - print("cache verified: \(cacheVerified ? "yes" : "no")") - print("download command: \(downloadCommand)") - print("download checksums: \(downloadChecksumsCommand)") - print("verify checksum: \(checksumVerifyCommand)") - print("attestation verify: \(verifyCommand)") - if manifest == nil { - print("note: this build has no embedded remote daemon manifest. Set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.") - } - } - - private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) { - let normalizedOS = requestedOS? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let normalizedArch = requestedArch? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS()) - let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch()) - return (goOS, goArch) - } - - private func hostGoOS() -> String { -#if os(macOS) - return "darwin" -#elseif os(Linux) - return "linux" -#else - return "unknown" -#endif - } - - private func hostGoArch() -> String { -#if arch(arm64) - return "arm64" -#elseif arch(x86_64) - return "amd64" -#else - return "unknown" -#endif - } - - private func remoteDaemonManifest() -> RemoteDaemonManifest? { - for plistURL in candidateInfoPlistURLs() { - guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any], - let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String, - let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), - let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else { - continue - } - return manifest - } - return nil - } - - private func remoteDaemonVersionString(from info: [String: String]) -> String { - info["CFBundleShortVersionString"] ?? "dev" - } - - private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL { - let root: URL - do { - root = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - } catch { - return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("cmux-remote-daemons", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - return root - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("remote-daemons", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - private func sha256Hex(forFile url: URL) throws -> String { - let data = try Data(contentsOf: url) - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() - if token == loweredKey { - return true - } - } - return false - } - - private func deferredRemoteReconnectLocalCommand( - in options: [String], - localCLIPath: String?, - foregroundAuthToken: String - ) -> String? { - guard shouldDeferRemoteReconnect(in: options) else { return nil } - let preferredCLIPath = localCLIPath?.trimmingCharacters(in: .whitespacesAndNewlines) - let escapedForegroundAuthToken = foregroundAuthToken - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return [ - preferredCLIPath.map { "programa_reconnect_cli=\(shellQuote($0));" } ?? "programa_reconnect_cli=\"\";", - "programa_reconnect_socket=\"${PROGRAMA_SOCKET_PATH:-${PROGRAMA_SOCKET:-}}\";", - "if [ -z \"$programa_reconnect_cli\" ] && [ -n \"${PROGRAMA_BUNDLED_CLI_PATH:-}\" ]; then programa_reconnect_cli=\"$PROGRAMA_BUNDLED_CLI_PATH\"; fi;", - "if [ ! -x \"$programa_reconnect_cli\" ]; then programa_reconnect_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi;", - "if [ -n \"${PROGRAMA_WORKSPACE_ID:-}\" ]; then", - "if [ -z \"$programa_reconnect_socket\" ]; then printf '%s\\n' 'cmux: deferred SSH reconnect skipped, local cmux socket not found' >&2;", - "elif [ -z \"$programa_reconnect_cli\" ] || [ ! -x \"$programa_reconnect_cli\" ]; then printf '%s\\n' 'cmux: deferred SSH reconnect skipped, local cmux CLI not found' >&2;", - "else", - "programa_reconnect_payload=\"{\\\"workspace_id\\\":\\\"$PROGRAMA_WORKSPACE_ID\\\",\\\"foreground_auth_token\\\":\\\"\(escapedForegroundAuthToken)\\\"}\";", - "\"$programa_reconnect_cli\" --socket \"$programa_reconnect_socket\" rpc workspace.remote.foreground_auth_ready \"$programa_reconnect_payload\" >/dev/null 2>&1 || true;", - "unset programa_reconnect_payload;", - "fi;", - "fi;", - "unset programa_reconnect_socket programa_reconnect_cli;", - ].joined(separator: " ") - } - - private func shouldDeferRemoteReconnect(in options: [String]) -> Bool { - guard !hasSSHOptionKey(options, key: "LocalCommand"), - !hasSSHOptionKey(options, key: "PermitLocalCommand") else { - return false - } - - guard let controlPath = sshOptionValue(named: "ControlPath", in: options)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !controlPath.isEmpty, - controlPath.lowercased() != "none" else { - return false - } - - let controlMaster = sshOptionValue(named: "ControlMaster", in: options)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() ?? "auto" - switch controlMaster { - case "no", "false", "off": - return false - default: - return true - } - } - - private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { - if let remoteRelayPort, remoteRelayPort > 0 { - return "/tmp/programa-ssh-\(getuid())-\(remoteRelayPort)-%C" - } - return "/tmp/programa-ssh-\(getuid())-%C" - } - - private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? { - guard let rawPath else { return nil } - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if trimmed.hasPrefix("~") { - let expanded = (trimmed as NSString).expandingTildeInPath - if !expanded.isEmpty { - return expanded - } - } - return trimmed - } - - private func shellQuote(_ value: String) -> String { - let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" - if value.range(of: safePattern, options: .regularExpression) != nil { - return value - } - return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - - private func sshOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let parts = trimmed.split( - maxSplits: 1, - omittingEmptySubsequences: true, - whereSeparator: { $0 == "=" || $0.isWhitespace } - ) - if parts.count == 2, parts[0].lowercased() == loweredKey { - let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } - } - } - return nil - } - - private func cliDebugLog(_ message: @autoclosure () -> String) { -#if DEBUG - let trimmedExplicit = ProcessInfo.processInfo.environment["PROGRAMA_DEBUG_LOG"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let path: String? = { - if let trimmedExplicit, !trimmedExplicit.isEmpty { - return trimmedExplicit - } - guard let marker = try? String(contentsOfFile: "/tmp/programa-last-debug-log-path", encoding: .utf8) else { - return nil - } - let trimmedMarker = marker.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedMarker.isEmpty ? nil : trimmedMarker - }() - guard let path else { return } - let timestamp = ISO8601DateFormatter().string(from: Date()) - let line = "\(timestamp) [programa-cli] \(message())\n" - guard let data = line.data(using: .utf8) else { return } - if !FileManager.default.fileExists(atPath: path) { - FileManager.default.createFile(atPath: path, contents: nil) - } - guard let handle = FileHandle(forWritingAtPath: path) else { return } - defer { try? handle.close() } - do { - try handle.seekToEnd() - try handle.write(contentsOf: data) - } catch { - return - } -#endif - } - - private func runProcess( - executablePath: String, - arguments: [String], - stdinText: String? = nil, - timeout: TimeInterval? = nil - ) -> (status: Int32, stdout: String, stderr: String) { - let result = CLIProcessRunner.runProcess( - executablePath: executablePath, - arguments: arguments, - stdinText: stdinText, - timeout: timeout - ) - return (result.status, result.stdout, result.stderr) - } - - /// Subcommand help text for SSH commands, split out of the - /// central `subcommandUsage` switch (programa.swift) so each domain's - /// help text lives next to its command descriptors. Refs #101. - func sshSubcommandUsage(_ command: String) -> String? { - switch command { - case "ssh": - return """ - Usage: programa ssh [flags] [-- ] - - Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. - programa will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. - - Flags: - --name Optional workspace title - --port <n> SSH port - --identity <path> SSH identity file path - --ssh-option <opt> Extra SSH -o option (repeatable) - --no-focus Create workspace without switching to it - - Example: - programa ssh dev@my-host - programa ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 - programa ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no - """ - case "remote-daemon-status": - return """ - Usage: programa remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] - - Show the embedded programad-remote release manifest, local cache status, checksum verification state, - and the GitHub attestation verification command for a target platform. - - Example: - programa remote-daemon-status - programa remote-daemon-status --os linux --arch arm64 - """ - default: - return nil - } - } - - /// SSH-related command descriptors, split out of the central - /// `commandDescriptors()` array (programa.swift) so they live next to - /// their implementation. Refs #101. - func sshDescriptors() -> [CommandDescriptor] { - [ - CommandDescriptor( - names: ["ssh"], - helpLines: ["ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>]"], - execute: { ctx in - try self.runSSH(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - CommandDescriptor( - names: ["ssh-session-end"], - helpLines: [], - execute: { ctx in - try self.runSSHSessionEnd(commandArgs: ctx.commandArgs, client: ctx.client) - } - ), - CommandDescriptor( - names: ["remote-daemon-status"], - helpLines: ["remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>]"], - connectionPolicy: .local, - execute: nil - ), - ] - } -} diff --git a/CLI/CLICommandDispatcher.swift b/CLI/CLICommandDispatcher.swift index 480e1645..9fe6d9fd 100644 --- a/CLI/CLICommandDispatcher.swift +++ b/CLI/CLICommandDispatcher.swift @@ -134,9 +134,6 @@ struct CLICommandDispatcher { case "version": print(cli.versionSummary()) return - case "remote-daemon-status": - try cli.runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput) - return case "help": print(cli.usage()) return diff --git a/CLI/programa.swift b/CLI/programa.swift index 6e9be17e..96483a11 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -203,16 +203,6 @@ enum SocketPasswordResolver { // docs/plans/mcp-server.md §1.4/§6. final class SocketClient { - private struct RelayEndpoint { - let host: String - let port: UInt16 - } - - private struct RelayCredentials { - let relayID: String - let relayToken: Data - } - private let path: String private var socketFD: Int32 = -1 private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 @@ -237,18 +227,6 @@ final class SocketClient { path } - private var relayEndpoint: RelayEndpoint? { - Self.parseRelayEndpoint(path) - } - - private static func trimmedEnvValue(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { - return nil - } - return trimmed - } - private static func socketTimeval(for timeout: TimeInterval) -> timeval { let sanitizedTimeout = timeout.isFinite ? timeout : defaultResponseTimeoutSeconds let clampedTimeout = min(max(sanitizedTimeout, 0.01), maxSocketTimeoutSeconds) @@ -303,16 +281,7 @@ final class SocketClient { /// `CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC`'s default (e.g. `surface.wait` with a caller-chosen /// `--timeout`). Ignored (falls back to the default) when `nil` or smaller. func send(command: String, minimumReceiveTimeout: TimeInterval? = nil) throws -> String { - if relayEndpoint != nil, socketFD < 0 { - try connect() - } guard socketFD >= 0 else { throw CLIError(message: "Not connected") } - let shouldCloseAfterSend = relayEndpoint != nil - defer { - if shouldCloseAfterSend { - close() - } - } let payload = command + "\n" try writeAll( @@ -368,11 +337,6 @@ final class SocketClient { } private func connectOnce() throws { - if let relayEndpoint { - try connectToRelay(endpoint: relayEndpoint) - return - } - // Verify socket is owned by the current user to prevent fake-socket attacks. var st = stat() guard stat(path, &st) == 0 else { @@ -425,154 +389,6 @@ final class SocketClient { ) } - private static func parseRelayEndpoint(_ raw: String) -> RelayEndpoint? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - !trimmed.hasPrefix("/") else { - return nil - } - let components = trimmed.split(separator: ":", omittingEmptySubsequences: false) - guard components.count == 2, - let port = UInt16(components[1]), - port > 0 else { - return nil - } - let host = String(components[0]).lowercased() - guard host == "127.0.0.1" || host == "localhost" else { - return nil - } - return RelayEndpoint(host: host == "localhost" ? "127.0.0.1" : host, port: port) - } - - private static func relayCredentials(for endpoint: RelayEndpoint) throws -> RelayCredentials { - let environment = ProcessInfo.processInfo.environment - if let relayID = trimmedEnvValue(environment["PROGRAMA_RELAY_ID"]), - let relayTokenHex = trimmedEnvValue(environment["PROGRAMA_RELAY_TOKEN"]), - let relayToken = hexData(from: relayTokenHex) { - return RelayCredentials(relayID: relayID, relayToken: relayToken) - } - - let authURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent(".programa/relay/\(endpoint.port).auth", isDirectory: false) - guard let authData = try? Data(contentsOf: authURL), - let authObject = try? JSONSerialization.jsonObject(with: authData) as? [String: Any], - let relayID = trimmedEnvValue(authObject["relay_id"] as? String), - let relayTokenHex = trimmedEnvValue(authObject["relay_token"] as? String), - let relayToken = hexData(from: relayTokenHex) else { - throw CLIError(message: "Missing relay auth metadata for \(endpoint.host):\(endpoint.port)") - } - - return RelayCredentials(relayID: relayID, relayToken: relayToken) - } - - private static func hexData(from string: String) -> Data? { - let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, - normalized.count.isMultiple(of: 2) else { - return nil - } - - var data = Data(capacity: normalized.count / 2) - var cursor = normalized.startIndex - while cursor < normalized.endIndex { - let next = normalized.index(cursor, offsetBy: 2) - guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { - return nil - } - data.append(byte) - cursor = next - } - return data - } - - private static func hexString(from data: Data) -> String { - data.map { String(format: "%02x", $0) }.joined() - } - - private func connectToRelay(endpoint: RelayEndpoint) throws { - let credentials = try Self.relayCredentials(for: endpoint) - - socketFD = socket(AF_INET, SOCK_STREAM, 0) - guard socketFD >= 0 else { - throw CLIError(message: "Failed to create relay socket") - } - do { - try configureSocketWriteSafety(Self.responseTimeoutSeconds) - try configureReceiveTimeout(Self.responseTimeoutSeconds) - } catch { - close() - throw error - } - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout<sockaddr_in>.stride) - address.sin_family = sa_family_t(AF_INET) - address.sin_port = endpoint.port.bigEndian - let parsedAddress = withUnsafeMutablePointer(to: &address.sin_addr) { pointer in - endpoint.host.withCString { hostPointer in - inet_pton(AF_INET, hostPointer, pointer) - } - } - guard parsedAddress == 1 else { - close() - throw CLIError(message: "Invalid relay endpoint \(endpoint.host):\(endpoint.port)") - } - - let result = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.stride)) - } - } - if result != 0 { - let connectErrno = errno - close() - throw CLIError( - message: "Failed to connect to relay at \(endpoint.host):\(endpoint.port) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" - ) - } - - do { - try authenticateRelay(credentials: credentials) - } catch { - close() - throw error - } - } - - private func authenticateRelay(credentials: RelayCredentials) throws { - let challengeLine = try readLine() - guard let challengeData = challengeLine.data(using: .utf8), - let challenge = try JSONSerialization.jsonObject(with: challengeData) as? [String: Any], - (challenge["protocol"] as? String) == "programa-relay-auth", - let version = challenge["version"] as? Int, - let relayID = challenge["relay_id"] as? String, - relayID == credentials.relayID, - let nonce = challenge["nonce"] as? String, - !nonce.isEmpty else { - throw CLIError(message: "Invalid relay authentication challenge") - } - - let authMessage = Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) - let key = SymmetricKey(data: credentials.relayToken) - let mac = Data(HMAC<SHA256>.authenticationCode(for: authMessage, using: key)) - let authPayload = try JSONSerialization.data(withJSONObject: [ - "relay_id": relayID, - "mac": Self.hexString(from: mac), - ]) - try writeAll( - authPayload + Data([0x0A]), - timeoutMessage: "Relay command timed out", - failureMessage: "Failed to write to relay socket" - ) - - let authResponseLine = try readLine() - guard let authResponseData = authResponseLine.data(using: .utf8), - let authResponse = try JSONSerialization.jsonObject(with: authResponseData) as? [String: Any], - (authResponse["ok"] as? Bool) == true else { - throw CLIError(message: "Relay authentication failed") - } - } - private func writeAll( _ data: Data, timeoutMessage: String, @@ -640,41 +456,6 @@ final class SocketClient { #endif } - private func readLine(maxBytes: Int = 16 * 1024) throws -> String { - var data = Data() - - while data.count < maxBytes { - try configureReceiveTimeout(Self.responseTimeoutSeconds) - - var byte: UInt8 = 0 - let count = Darwin.read(socketFD, &byte, 1) - if count < 0 { - if errno == EINTR { - continue - } - if errno == EAGAIN || errno == EWOULDBLOCK { - throw CLIError(message: "Relay command timed out") - } - throw CLIError(message: "Relay socket read error") - } - if count == 0 { - break - } - if byte == 0x0A { - break - } - data.append(byte) - } - - guard !data.isEmpty else { - throw CLIError(message: "Unexpected EOF from relay") - } - guard let line = String(data: data, encoding: .utf8) else { - throw CLIError(message: "Invalid UTF-8 relay response") - } - return line.trimmingCharacters(in: .whitespacesAndNewlines) - } - private func configureReceiveTimeout(_ timeout: TimeInterval) throws { var interval = Self.socketTimeval(for: timeout) let result = withUnsafePointer(to: &interval) { ptr in @@ -694,9 +475,6 @@ final class SocketClient { static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { let client = SocketClient(path: path) if (try? client.connect()) != nil { - if client.relayEndpoint != nil { - client.close() - } return client } @@ -1980,18 +1758,10 @@ struct ProgramaCLI { let selected = (ws["selected"] as? Bool) == true let handle = self.textHandle(ws, idFormat: ctx.idFormat) let title = (ws["title"] as? String) ?? "" - let remoteTag: String = { - guard let remote = ws["remote"] as? [String: Any], - (remote["enabled"] as? Bool) == true else { - return "" - } - let state = (remote["state"] as? String) ?? "unknown" - return " [ssh:\(state)]" - }() let prefix = selected ? "* " : " " let selTag = selected ? " [selected]" : "" let titlePart = title.isEmpty ? "" : " \(title)" - print("\(prefix)\(handle)\(titlePart)\(remoteTag)\(selTag)") + print("\(prefix)\(handle)\(titlePart)\(selTag)") } } } @@ -2051,7 +1821,6 @@ struct ProgramaCLI { ), ] - descriptors += self.sshDescriptors() descriptors += [ CommandDescriptor( @@ -6078,40 +5847,6 @@ struct ProgramaCLI { windowOverride: windowOverride ) } - struct SSHCommandOptions { - let destination: String - let port: Int? - let identityFile: String? - let workspaceName: String? - let noFocus: Bool - let sshOptions: [String] - let extraArguments: [String] - let localSocketPath: String - let remoteRelayPort: Int - } - - struct RemoteDaemonManifest: Decodable { - struct Entry: Decodable { - let goOS: String - let goArch: String - let assetName: String - let downloadURL: String - let sha256: String - } - - let schemaVersion: Int - let appVersion: String - let releaseTag: String - let releaseURL: String - let checksumsAssetName: String - let checksumsURL: String - let entries: [Entry] - - func entry(goOS: String, goArch: String) -> Entry? { - entries.first { $0.goOS == goOS && $0.goArch == goArch } - } - } - func resolveWorkspaceId(_ raw: String?, client: SocketClient) throws -> String { if let raw, isUUID(raw) { return raw @@ -6170,7 +5905,6 @@ struct ProgramaCLI { /// Return the help/usage text for a subcommand, or nil if the command is unknown. private func subcommandUsage(_ command: String) -> String? { if let text = tmuxCompatSubcommandUsage(command) { return text } - if let text = sshSubcommandUsage(command) { return text } if let text = treeSubcommandUsage(command) { return text } if let text = hooksSubcommandUsage(command) { return text } if let text = browserSubcommandUsage(command) { return text } @@ -6658,24 +6392,6 @@ struct ProgramaCLI { throw CLIError(message: "layout: unknown subcommand \(parsed.positional[0])") } - case "ssh": - try validateSSHCommandArguments(args) - - case "ssh-session-end": - let parsed = try parse(values: ["relay-port", "workspace", "surface"]) - try require(["relay-port"], in: parsed.options) - guard let rawPort = parsed.options["relay-port"], let port = Int(rawPort), port > 0, port <= 65535 else { - throw CLIError(message: "ssh-session-end: --relay-port must be 1-65535") - } - if parsed.options["workspace"] == nil, - ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] == nil { - throw CLIError(message: "ssh-session-end requires --workspace or PROGRAMA_WORKSPACE_ID") - } - if parsed.options["surface"] == nil, - ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] == nil { - throw CLIError(message: "ssh-session-end requires --surface or PROGRAMA_SURFACE_ID") - } - case "claude-hook": let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1) if let subcommand = parsed.positional.first?.lowercased(), @@ -6754,15 +6470,6 @@ struct ProgramaCLI { return case "codex", "claude", "opencode": _ = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) - case "remote-daemon-status": - let parsed = try parse(values: ["os", "arch"]) - if let os = parsed.options["os"], !["darwin", "linux"].contains(os.lowercased()) { - throw CLIError(message: "remote-daemon-status: unsupported --os value") - } - if let arch = parsed.options["arch"], !["arm64", "amd64"].contains(arch.lowercased()) { - throw CLIError(message: "remote-daemon-status: unsupported --arch value") - } - // Commands with richer bespoke contracts are validated by their // dedicated cases in `validateArguments`. case "ping", "focus-panel", "read-screen", "wait-surface", "set-progress", "list-log", "watch-events": diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index c3be3281..fea326b4 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -20,7 +20,6 @@ A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; NRSP0084A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */; }; - NRSP0082A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0081A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift */; }; NRSP0080A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0079A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift */; }; NRSP0078A1B2C3D4E5F60719 /* Workspace+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0077A1B2C3D4E5F60719 /* Workspace+Theme.swift */; }; NRSP0076A1B2C3D4E5F60719 /* Workspace+Surfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */; }; @@ -42,7 +41,6 @@ AMDT000007 /* AgentDetection in Resources */ = {isa = PBXBuildFile; fileRef = AMDT000008 /* AgentDetection */; }; AMDT000009 /* AgentManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AMDT000010 /* AgentManifestTests.swift */; }; CQTT000001 /* ClaudeQuotaSnapshotParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CQTT000002 /* ClaudeQuotaSnapshotParserTests.swift */; }; - NRSP0072A1B2C3D4E5F60719 /* Workspace+Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0071A1B2C3D4E5F60719 /* Workspace+Remote.swift */; }; NRSP0070A1B2C3D4E5F60719 /* Workspace+FocusGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0069A1B2C3D4E5F60719 /* Workspace+FocusGeometry.swift */; }; NRSP0068A1B2C3D4E5F60719 /* WindowTerminalHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0067A1B2C3D4E5F60719 /* WindowTerminalHostView.swift */; }; NRSP0066A1B2C3D4E5F60719 /* WindowOverlayControllers.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0065A1B2C3D4E5F60719 /* WindowOverlayControllers.swift */; }; @@ -59,7 +57,6 @@ CESH000001 /* session_escrow_shim.c in Sources */ = {isa = PBXBuildFile; fileRef = CESH000002 /* session_escrow_shim.c */; }; NRSP0052A1B2C3D4E5F60719 /* TerminalCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0051A1B2C3D4E5F60719 /* TerminalCopyMode.swift */; }; NRSP0050A1B2C3D4E5F60719 /* SidebarShortcutHints.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0049A1B2C3D4E5F60719 /* SidebarShortcutHints.swift */; }; - NRSP0048A1B2C3D4E5F60719 /* SidebarRemoteErrorCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0047A1B2C3D4E5F60719 /* SidebarRemoteErrorCopy.swift */; }; NRSP0046A1B2C3D4E5F60719 /* ProgramaSurfaceConfigTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0045A1B2C3D4E5F60719 /* ProgramaSurfaceConfigTemplate.swift */; }; NRSP0020A1B2C3D4E5F60719 /* InternalTabDrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0019A1B2C3D4E5F60719 /* InternalTabDrag.swift */; }; NRSP0018A1B2C3D4E5F60719 /* GhosttyTerminalSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRSP0017A1B2C3D4E5F60719 /* GhosttyTerminalSupport.swift */; }; @@ -108,8 +105,7 @@ A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5FF0008 /* HostedViewPortalRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0018 /* HostedViewPortalRegistry.swift */; }; A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; - A5001542 /* TerminalImageTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalImageTransfer.swift */; }; - A5001543 /* TerminalSSHSessionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001545 /* TerminalSSHSessionDetector.swift */; }; + A5001542 /* TerminalPasteboardPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001544 /* TerminalPasteboardPlanner.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; H1AP0002 /* AppLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1AP0001 /* AppLifecycleCoordinator.swift */; }; @@ -132,7 +128,6 @@ A5001500 /* ProgramaWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* ProgramaWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; - A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; }; D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; }; D1320AA0D1320AA0D1320AA2 /* ProgramaDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* ProgramaDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; @@ -153,17 +148,13 @@ NRBR0001 /* InspectorDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0002 /* InspectorDock.swift */; }; NRBR0003 /* BrowserWebDialogPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0004 /* BrowserWebDialogPresenter.swift */; }; NRBR0005 /* BrowserProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0006 /* BrowserProfileStore.swift */; }; - 1MPW0001 /* BrowserImportWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1MPW0002 /* BrowserImportWizardView.swift */; }; + BA000001A1B2C3D4E5F60720 /* BrowserAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA000002A1B2C3D4E5F60720 /* BrowserAvailability.swift */; }; NRBR0007 /* BrowserHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0008 /* BrowserHistoryStore.swift */; }; NRBR0009 /* BrowserDownloadDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0010 /* BrowserDownloadDelegate.swift */; }; NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0012 /* BrowserPanelWebDelegates.swift */; }; NRBR0013 /* BrowserUserProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0014 /* BrowserUserProxySettings.swift */; }; NRBR0015 /* IMECompositionMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0016 /* IMECompositionMessageHandler.swift */; }; - NRBR0019 /* BrowserExtensionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0020 /* BrowserExtensionManager.swift */; }; - NRBR0021 /* BrowserExtensionAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0022 /* BrowserExtensionAdapters.swift */; }; NRBR0017 /* WebViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRBR0018 /* WebViewRepresentable.swift */; }; - A5FF0013 /* BrowserDataImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0003 /* BrowserDataImport.swift */; }; - A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; }; DSGN000001 /* DesignMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DSGN000002 /* DesignMode.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; @@ -188,20 +179,6 @@ NRWS00000000000000000028 /* Workspace+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000027 /* Workspace+Persistence.swift */; }; NRWS00000000000000000030 /* Workspace+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000029 /* Workspace+Layout.swift */; }; NRWS00000000000000000032 /* Workspace+Bonsplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000031 /* Workspace+Bonsplit.swift */; }; - A5FF0012 /* WorkspaceRemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0002 /* WorkspaceRemoteSession.swift */; }; - NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */; }; - NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */; }; - NRWS00000000000000000022 /* WorkspaceRemoteSessionController+DaemonInstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */; }; - NRWS00000000000000000024 /* WorkspaceRemoteSessionController+ScriptBuilders.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */; }; - NRWS00000000000000000026 /* WorkspaceRemoteSessionController+PortScanning.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */; }; - NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */; }; - NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */; }; - NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */; }; - NRWS00000000000000000012 /* WorkspaceRemoteLoopbackHTTPRewriting.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */; }; - NRWS00000000000000000014 /* WorkspaceRemoteProxyBroker.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */; }; - NRWS00000000000000000016 /* WorkspaceRemoteCLIRelayServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */; }; - NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */; }; - NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRWS00000000000000000003 /* RemoteSCPUpload.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; E3E96C3E34893DE5905D6218 /* SessionAutosaveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5E68FD3C9918B5717603304 /* SessionAutosaveCoordinator.swift */; }; @@ -210,7 +187,6 @@ NRAD1002 /* TypingProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0002 /* TypingProfiler.swift */; }; 704EC7ED55706CC3099E0ED9 /* DurationSamples.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974E18832220A6E017B9CA2E /* DurationSamples.swift */; }; NRAD1003 /* TerminalDirectoryOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0003 /* TerminalDirectoryOpener.swift */; }; - NRAD1004 /* VSCodeIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0004 /* VSCodeIntegration.swift */; }; NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0005 /* WorkspaceShortcutMapper.swift */; }; NRAD1006 /* CLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0006 /* CLIInstaller.swift */; }; NRAD1007 /* MenuBarIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0007 /* MenuBarIconRenderer.swift */; }; @@ -243,21 +219,13 @@ A5001207 /* UpdatePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001217 /* UpdatePopoverView.swift */; }; A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; }; - A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001650 /* ProgramaConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* ProgramaConfig.swift */; }; A5001652 /* ProgramaConfigExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001653 /* ProgramaConfigExecutor.swift */; }; NRPA00001 /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRPA00002 /* FileWatcher.swift */; }; A5001654 /* ProgramaDirectoryTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* ProgramaDirectoryTrust.swift */; }; A5001660 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* JSONCParser.swift */; }; - MOBB0001 /* MobileBridgeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0002 /* MobileBridgeSettings.swift */; }; - MOBB0003 /* MobileBridgeStreamSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0004 /* MobileBridgeStreamSupport.swift */; }; - MOBB0005 /* MobileBridgeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0006 /* MobileBridgeListener.swift */; }; - MOBB0007 /* MobileBridgeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0008 /* MobileBridgeSession.swift */; }; - MOBB0009 /* MobileBridgePush.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0010 /* MobileBridgePush.swift */; }; - MOBB0011 /* MobileBridgePairingCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = MOBB0012 /* MobileBridgePairingCode.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; - MOBB1003 /* IrohLib in Frameworks */ = {isa = PBXBuildFile; productRef = MOBB1002 /* IrohLib */; }; B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* programa.swift */; }; BA930A082EE6778208DFF1D5 /* programa-mcp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6F1F29768816E717790E5 /* programa-mcp.swift */; }; 21C3DEC154B0CB6666377FEC /* MCPServer+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5780162BC419AEE022390A45 /* MCPServer+Capabilities.swift */; }; @@ -286,7 +254,6 @@ 3288DF69B494C40EF8EBC2F1 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = 8B642EDBCCB415DF7E33F5C4 /* MCP */; }; 6C9DA4528D8FCFF501A5FA8C /* programa-mcp in Copy CLI */ = {isa = PBXBuildFile; fileRef = 0AC9A9E0A68EF163F87A0C83 /* programa-mcp */; }; B9000031A1B2C3D4E5F60719 /* CLI+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */; }; - B9000033A1B2C3D4E5F60719 /* CLI+SSH.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000032A1B2C3D4E5F60719 /* CLI+SSH.swift */; }; B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */; }; B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */; }; B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */; }; @@ -296,7 +263,6 @@ H1HC0002 /* CLI+HookCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1HC0001 /* CLI+HookCommands.swift */; }; H1CL0002 /* CLICommandDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1CL0001 /* CLICommandDispatcher.swift */; }; H1HK0002 /* HookInstallationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1HK0001 /* HookInstallationCoordinator.swift */; }; - B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; B900000BA1B2C3D4E5F60719 /* programa in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* programa */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; @@ -316,7 +282,6 @@ B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; - FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; @@ -326,11 +291,8 @@ RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */; }; 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */; }; SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */; }; - FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; E57B23C339CE9F1226BC4478 /* ShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C838E51ABC8DBDCC2040D56 /* ShortcutRoutingTests.swift */; }; - F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; - A9D4C7E2B16F4830C5A70212 /* MobileBridgeConnectionRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D4C7E2B16F4830C5A70211 /* MobileBridgeConnectionRegistryTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; @@ -341,7 +303,6 @@ RPVW0002 /* ReviewPanelViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RPVW0001 /* ReviewPanelViewTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; - A5001623 /* programa.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* programa.sdef */; }; MRMD000003 /* mermaid.min.js in Resources */ = {isa = PBXBuildFile; fileRef = MRMD000004 /* mermaid.min.js */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; @@ -365,7 +326,6 @@ 7C135CU00000000000000004 /* ClosedTerminalUndoStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C135CU00000000000000003 /* ClosedTerminalUndoStoreTests.swift */; }; 7C10SE2A0000000000000002 /* WorkspaceCloseConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C10SE2A0000000000000001 /* WorkspaceCloseConfirmationTests.swift */; }; C1A2B3C4D5E6F70800000001 /* ProgramaConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000002 /* ProgramaConfigTests.swift */; }; - C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */; }; 9EAF4699346282F2171FCDB7 /* ProgramaDirectoryTrustTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA3597273B50CC0592C0164 /* ProgramaDirectoryTrustTests.swift */; }; /* End PBXBuildFile section */ @@ -460,7 +420,6 @@ A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = "<group>"; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarModels.swift; sourceTree = "<group>"; }; - NRSP0081A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteModels.swift; sourceTree = "<group>"; }; NRSP0079A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceMountPresentation.swift; sourceTree = "<group>"; }; NRSP0077A1B2C3D4E5F60719 /* Workspace+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Theme.swift"; sourceTree = "<group>"; }; NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Surfaces.swift"; sourceTree = "<group>"; }; @@ -482,7 +441,6 @@ AMDT000008 /* AgentDetection */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AgentDetection; sourceTree = "<group>"; }; AMDT000010 /* AgentManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentManifestTests.swift; sourceTree = "<group>"; }; CQTT000002 /* ClaudeQuotaSnapshotParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeQuotaSnapshotParserTests.swift; sourceTree = "<group>"; }; - NRSP0071A1B2C3D4E5F60719 /* Workspace+Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Remote.swift"; sourceTree = "<group>"; }; NRSP0069A1B2C3D4E5F60719 /* Workspace+FocusGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+FocusGeometry.swift"; sourceTree = "<group>"; }; NRSP0067A1B2C3D4E5F60719 /* WindowTerminalHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTerminalHostView.swift; sourceTree = "<group>"; }; NRSP0065A1B2C3D4E5F60719 /* WindowOverlayControllers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowOverlayControllers.swift; sourceTree = "<group>"; }; @@ -498,7 +456,6 @@ SMGT000002 /* SessionMachineryGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMachineryGate.swift; sourceTree = "<group>"; }; NRSP0051A1B2C3D4E5F60719 /* TerminalCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCopyMode.swift; sourceTree = "<group>"; }; NRSP0049A1B2C3D4E5F60719 /* SidebarShortcutHints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarShortcutHints.swift; sourceTree = "<group>"; }; - NRSP0047A1B2C3D4E5F60719 /* SidebarRemoteErrorCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarRemoteErrorCopy.swift; sourceTree = "<group>"; }; NRSP0045A1B2C3D4E5F60719 /* ProgramaSurfaceConfigTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaSurfaceConfigTemplate.swift; sourceTree = "<group>"; }; NRSP0019A1B2C3D4E5F60719 /* InternalTabDrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTabDrag.swift; sourceTree = "<group>"; }; NRSP0017A1B2C3D4E5F60719 /* GhosttyTerminalSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalSupport.swift; sourceTree = "<group>"; }; @@ -547,8 +504,7 @@ A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; }; A5FF0018 /* HostedViewPortalRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedViewPortalRegistry.swift; sourceTree = "<group>"; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; }; - A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = "<group>"; }; - A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = "<group>"; }; + A5001544 /* TerminalPasteboardPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPasteboardPlanner.swift; sourceTree = "<group>"; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; }; A5001018 /* programa-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "programa-Bridging-Header.h"; sourceTree = "<group>"; }; @@ -572,7 +528,6 @@ NRTC0009 /* TerminalController+SurfaceWait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceWait.swift"; sourceTree = "<group>"; }; NRTC0010 /* TerminalController+Subscriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Subscriptions.swift"; sourceTree = "<group>"; }; NRTC0022 /* TerminalController+AgentPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentPrompt.swift"; sourceTree = "<group>"; }; - A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; }; D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = "<group>"; }; A5001510 /* ProgramaWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ProgramaWebView.swift; sourceTree = "<group>"; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; }; @@ -595,17 +550,13 @@ NRBR0002 /* InspectorDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/InspectorDock.swift; sourceTree = "<group>"; }; NRBR0004 /* BrowserWebDialogPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebDialogPresenter.swift; sourceTree = "<group>"; }; NRBR0006 /* BrowserProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserProfileStore.swift; sourceTree = "<group>"; }; - 1MPW0002 /* BrowserImportWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserImportWizardView.swift; sourceTree = "<group>"; }; + BA000002A1B2C3D4E5F60720 /* BrowserAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAvailability.swift; sourceTree = "<group>"; }; NRBR0008 /* BrowserHistoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHistoryStore.swift; sourceTree = "<group>"; }; NRBR0010 /* BrowserDownloadDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadDelegate.swift; sourceTree = "<group>"; }; NRBR0012 /* BrowserPanelWebDelegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelWebDelegates.swift; sourceTree = "<group>"; }; NRBR0014 /* BrowserUserProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserUserProxySettings.swift; sourceTree = "<group>"; }; NRBR0016 /* IMECompositionMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/IMECompositionMessageHandler.swift; sourceTree = "<group>"; }; - NRBR0020 /* BrowserExtensionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserExtensionManager.swift; sourceTree = "<group>"; }; - NRBR0022 /* BrowserExtensionAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserExtensionAdapters.swift; sourceTree = "<group>"; }; NRBR0018 /* WebViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebViewRepresentable.swift; sourceTree = "<group>"; }; - A5FF0003 /* BrowserDataImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDataImport.swift; sourceTree = "<group>"; }; - A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = "<group>"; }; DSGN000002 /* DesignMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/DesignMode.swift; sourceTree = "<group>"; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; }; @@ -629,20 +580,6 @@ NRWS00000000000000000027 /* Workspace+Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Persistence.swift"; sourceTree = "<group>"; }; NRWS00000000000000000029 /* Workspace+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Layout.swift"; sourceTree = "<group>"; }; NRWS00000000000000000031 /* Workspace+Bonsplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+Bonsplit.swift"; sourceTree = "<group>"; }; - A5FF0002 /* WorkspaceRemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSession.swift; sourceTree = "<group>"; }; - NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ConnectionOrchestration.swift"; sourceTree = "<group>"; }; - NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ProcessExecution.swift"; sourceTree = "<group>"; }; - NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+DaemonInstall.swift"; sourceTree = "<group>"; }; - NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+ScriptBuilders.swift"; sourceTree = "<group>"; }; - NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceRemoteSessionController+PortScanning.swift"; sourceTree = "<group>"; }; - NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonPendingCallRegistry.swift; sourceTree = "<group>"; }; - NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteSSHBatchCommandBuilder.swift; sourceTree = "<group>"; }; - NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemonRPCClient.swift; sourceTree = "<group>"; }; - NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteLoopbackHTTPRewriting.swift; sourceTree = "<group>"; }; - NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteProxyBroker.swift; sourceTree = "<group>"; }; - NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteCLIRelayServer.swift; sourceTree = "<group>"; }; - NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSSHConnectionPolicy.swift; sourceTree = "<group>"; }; - NRWS00000000000000000003 /* RemoteSCPUpload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSCPUpload.swift; sourceTree = "<group>"; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = "<group>"; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; C5E68FD3C9918B5717603304 /* SessionAutosaveCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinator.swift; sourceTree = "<group>"; }; @@ -651,7 +588,6 @@ NRAD0002 /* TypingProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingProfiler.swift; sourceTree = "<group>"; }; 974E18832220A6E017B9CA2E /* DurationSamples.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationSamples.swift; sourceTree = "<group>"; }; NRAD0003 /* TerminalDirectoryOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalDirectoryOpener.swift; sourceTree = "<group>"; }; - NRAD0004 /* VSCodeIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VSCodeIntegration.swift; sourceTree = "<group>"; }; NRAD0005 /* WorkspaceShortcutMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShortcutMapper.swift; sourceTree = "<group>"; }; NRAD0006 /* CLIInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstaller.swift; sourceTree = "<group>"; }; NRAD0007 /* MenuBarIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconRenderer.swift; sourceTree = "<group>"; }; @@ -688,14 +624,7 @@ A5001653 /* ProgramaConfigExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigExecutor.swift; sourceTree = "<group>"; }; NRPA00002 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = "<group>"; }; A5001655 /* ProgramaDirectoryTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrust.swift; sourceTree = "<group>"; }; - MOBB0002 /* MobileBridgeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeSettings.swift; sourceTree = "<group>"; }; - MOBB0004 /* MobileBridgeStreamSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeStreamSupport.swift; sourceTree = "<group>"; }; - MOBB0006 /* MobileBridgeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeListener.swift; sourceTree = "<group>"; }; - MOBB0008 /* MobileBridgeSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgeSession.swift; sourceTree = "<group>"; }; - MOBB0010 /* MobileBridgePush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePush.swift; sourceTree = "<group>"; }; - MOBB0012 /* MobileBridgePairingCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridge/MobileBridgePairingCode.swift; sourceTree = "<group>"; }; A5001661 /* JSONCParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCParser.swift; sourceTree = "<group>"; }; - A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = "<group>"; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; }; B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayResolutionRegressionUITests.swift; sourceTree = "<group>"; }; @@ -729,7 +658,6 @@ DFDBEC8463473B25B04AF031 /* ReviewTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReviewTools.swift"; sourceTree = "<group>"; }; 30DC1E7B0A701824297403A5 /* FocusTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusTools.swift"; sourceTree = "<group>"; }; B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Markdown.swift"; sourceTree = "<group>"; }; - B9000032A1B2C3D4E5F60719 /* CLI+SSH.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+SSH.swift"; sourceTree = "<group>"; }; B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Browser.swift"; sourceTree = "<group>"; }; B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Themes.swift"; sourceTree = "<group>"; }; B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Tree.swift"; sourceTree = "<group>"; }; @@ -750,7 +678,6 @@ B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; }; - FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; }; AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = "<group>"; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = "<group>"; }; @@ -761,11 +688,8 @@ RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RendererRealizationPlannerTests.swift; sourceTree = "<group>"; }; D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAutosaveCoordinatorTests.swift; sourceTree = "<group>"; }; SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionWALCoreTests.swift; sourceTree = "<group>"; }; - FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; }; 6C838E51ABC8DBDCC2040D56 /* ShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRoutingTests.swift; sourceTree = "<group>"; }; - F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = "<group>"; }; - A9D4C7E2B16F4830C5A70211 /* MobileBridgeConnectionRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileBridgeConnectionRegistryTests.swift; sourceTree = "<group>"; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; }; @@ -776,7 +700,6 @@ RPVW0001 /* ReviewPanelViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPanelViewTests.swift; sourceTree = "<group>"; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; }; - A5001622 /* programa.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = programa.sdef; sourceTree = "<group>"; }; MRMD000004 /* mermaid.min.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = mermaid.min.js; sourceTree = "<group>"; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = "<group>"; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = "<group>"; }; @@ -800,7 +723,6 @@ 7C135CU00000000000000003 /* ClosedTerminalUndoStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedTerminalUndoStoreTests.swift; sourceTree = "<group>"; }; 7C10SE2A0000000000000001 /* WorkspaceCloseConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCloseConfirmationTests.swift; sourceTree = "<group>"; }; C1A2B3C4D5E6F70800000002 /* ProgramaConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaConfigTests.swift; sourceTree = "<group>"; }; - C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServeWebPortStoreTests.swift; sourceTree = "<group>"; }; 3CA3597273B50CC0592C0164 /* ProgramaDirectoryTrustTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramaDirectoryTrustTests.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -812,7 +734,6 @@ A5001006 /* GhosttyKit.xcframework in Frameworks */, A5001230 /* Sparkle in Frameworks */, A5001290 /* MarkdownUI in Frameworks */, - MOBB1003 /* IrohLib in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -866,7 +787,6 @@ AMDT000007 /* AgentDetection in Resources */, DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */, - A5001623 /* programa.sdef in Resources */, MRMD000003 /* mermaid.min.js in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -951,7 +871,6 @@ A5001012 /* ContentView.swift */, B10A1CE5 /* RenderableSystemSymbol.swift */, NRSP0083A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift */, - NRSP0081A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift */, NRSP0079A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift */, NRSP0077A1B2C3D4E5F60719 /* Workspace+Theme.swift */, NRSP0075A1B2C3D4E5F60719 /* Workspace+Surfaces.swift */, @@ -966,7 +885,6 @@ CQTA000002 /* ClaudeQuotaMonitor.swift */, CQTA000004 /* SidebarQuotaFooter.swift */, LCMD000002 /* LastCommandOutcome.swift */, - NRSP0071A1B2C3D4E5F60719 /* Workspace+Remote.swift */, NRSP0069A1B2C3D4E5F60719 /* Workspace+FocusGeometry.swift */, NRSP0067A1B2C3D4E5F60719 /* WindowTerminalHostView.swift */, NRSP0065A1B2C3D4E5F60719 /* WindowOverlayControllers.swift */, @@ -982,7 +900,6 @@ SMGT000002 /* SessionMachineryGate.swift */, NRSP0051A1B2C3D4E5F60719 /* TerminalCopyMode.swift */, NRSP0049A1B2C3D4E5F60719 /* SidebarShortcutHints.swift */, - NRSP0047A1B2C3D4E5F60719 /* SidebarRemoteErrorCopy.swift */, NRSP0045A1B2C3D4E5F60719 /* ProgramaSurfaceConfigTemplate.swift */, NRSP0019A1B2C3D4E5F60719 /* InternalTabDrag.swift */, NRSP0017A1B2C3D4E5F60719 /* GhosttyTerminalSupport.swift */, @@ -1025,20 +942,6 @@ NRWS00000000000000000027 /* Workspace+Persistence.swift */, NRWS00000000000000000029 /* Workspace+Layout.swift */, NRWS00000000000000000031 /* Workspace+Bonsplit.swift */, - A5FF0002 /* WorkspaceRemoteSession.swift */, - NRWS00000000000000000017 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift */, - NRWS00000000000000000019 /* WorkspaceRemoteSessionController+ProcessExecution.swift */, - NRWS00000000000000000021 /* WorkspaceRemoteSessionController+DaemonInstall.swift */, - NRWS00000000000000000023 /* WorkspaceRemoteSessionController+ScriptBuilders.swift */, - NRWS00000000000000000025 /* WorkspaceRemoteSessionController+PortScanning.swift */, - NRWS00000000000000000005 /* WorkspaceRemoteDaemonPendingCallRegistry.swift */, - NRWS00000000000000000007 /* WorkspaceRemoteSSHBatchCommandBuilder.swift */, - NRWS00000000000000000009 /* WorkspaceRemoteDaemonRPCClient.swift */, - NRWS00000000000000000011 /* WorkspaceRemoteLoopbackHTTPRewriting.swift */, - NRWS00000000000000000013 /* WorkspaceRemoteProxyBroker.swift */, - NRWS00000000000000000015 /* WorkspaceRemoteCLIRelayServer.swift */, - NRWS00000000000000000001 /* RemoteSSHConnectionPolicy.swift */, - NRWS00000000000000000003 /* RemoteSCPUpload.swift */, A5001417 /* WorkspaceContentView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, @@ -1073,10 +976,8 @@ NRTC0010 /* TerminalController+Subscriptions.swift */, NRTC0022 /* TerminalController+AgentPrompt.swift */, A5001541 /* PortScanner.swift */, - A5001544 /* TerminalImageTransfer.swift */, - A5001545 /* TerminalSSHSessionDetector.swift */, + A5001544 /* TerminalPasteboardPlanner.swift */, A5001225 /* SocketControlSettings.swift */, - A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, H1AP0001 /* AppLifecycleCoordinator.swift */, @@ -1086,7 +987,6 @@ NRAD0002 /* TypingProfiler.swift */, 974E18832220A6E017B9CA2E /* DurationSamples.swift */, NRAD0003 /* TerminalDirectoryOpener.swift */, - NRAD0004 /* VSCodeIntegration.swift */, NRAD0005 /* WorkspaceShortcutMapper.swift */, NRAD0006 /* CLIInstaller.swift */, NRAD0007 /* MenuBarIconRenderer.swift */, @@ -1119,17 +1019,13 @@ NRBR0002 /* InspectorDock.swift */, NRBR0004 /* BrowserWebDialogPresenter.swift */, NRBR0006 /* BrowserProfileStore.swift */, - 1MPW0002 /* BrowserImportWizardView.swift */, + BA000002A1B2C3D4E5F60720 /* BrowserAvailability.swift */, NRBR0008 /* BrowserHistoryStore.swift */, NRBR0010 /* BrowserDownloadDelegate.swift */, NRBR0012 /* BrowserPanelWebDelegates.swift */, NRBR0014 /* BrowserUserProxySettings.swift */, NRBR0016 /* IMECompositionMessageHandler.swift */, - NRBR0020 /* BrowserExtensionManager.swift */, - NRBR0022 /* BrowserExtensionAdapters.swift */, NRBR0018 /* WebViewRepresentable.swift */, - A5FF0003 /* BrowserDataImport.swift */, - A500RG00 /* ReactGrab.swift */, DSGN000002 /* DesignMode.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, @@ -1155,18 +1051,11 @@ A5001218 /* UpdateTitlebarAccessory.swift */, A5001222 /* WindowAccessor.swift */, A5001611 /* SessionPersistence.swift */, - A5001641 /* RemoteRelayZshBootstrap.swift */, A5001651 /* ProgramaConfig.swift */, A5001653 /* ProgramaConfigExecutor.swift */, NRPA00002 /* FileWatcher.swift */, A5001655 /* ProgramaDirectoryTrust.swift */, A5001661 /* JSONCParser.swift */, - MOBB0002 /* MobileBridgeSettings.swift */, - MOBB0004 /* MobileBridgeStreamSupport.swift */, - MOBB0006 /* MobileBridgeListener.swift */, - MOBB0008 /* MobileBridgeSession.swift */, - MOBB0010 /* MobileBridgePush.swift */, - MOBB0012 /* MobileBridgePairingCode.swift */, RVPN00000000000000000001 /* ReviewComment.swift */, RVPN00000000000000000003 /* ReviewCommentSerializer.swift */, RVPN00000000000000000005 /* ReviewDiffParser.swift */, @@ -1183,7 +1072,6 @@ children = ( B9000001A1B2C3D4E5F60719 /* programa.swift */, B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */, - B9000032A1B2C3D4E5F60719 /* CLI+SSH.swift */, B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */, B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */, B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */, @@ -1252,7 +1140,6 @@ C1ADE00001A1B2C3D4E5F719 /* claude */, DA7A10CA710E000000000001 /* Localizable.xcstrings */, DA7A10CA710E000000000002 /* InfoPlist.xcstrings */, - A5001622 /* programa.sdef */, MRMD000004 /* mermaid.min.js */, ); path = Resources; @@ -1287,7 +1174,6 @@ E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */, - FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */, C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */, AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */, @@ -1305,11 +1191,8 @@ RRPL0001A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift */, D3F772E2747D24AE2FAAC541 /* SessionAutosaveCoordinatorTests.swift */, SWCT000002A1B2C3D4E5F607 /* SessionWALCoreTests.swift */, - FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, 6C838E51ABC8DBDCC2040D56 /* ShortcutRoutingTests.swift */, - F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, - A9D4C7E2B16F4830C5A70211 /* MobileBridgeConnectionRegistryTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, @@ -1344,7 +1227,6 @@ 7C135CU00000000000000003 /* ClosedTerminalUndoStoreTests.swift */, 7C10SE2A0000000000000001 /* WorkspaceCloseConfirmationTests.swift */, C1A2B3C4D5E6F70800000002 /* ProgramaConfigTests.swift */, - C1A2B3C4D5E6F70800000005 /* ServeWebPortStoreTests.swift */, 3CA3597273B50CC0592C0164 /* ProgramaDirectoryTrustTests.swift */, A9A8BC17CBD749D48C344001 /* MCPSocketBridgeTests.swift */, ); @@ -1377,7 +1259,6 @@ A5001231 /* Sparkle */, A5001261 /* Bonsplit */, A5001291 /* MarkdownUI */, - MOBB1002 /* IrohLib */, ); name = GhosttyTabs; productName = GhosttyTabs; @@ -1498,7 +1379,6 @@ A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */, A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, A5001260 /* XCLocalSwiftPackageReference "bonsplit" */, - MOBB1001 /* XCRemoteSwiftPackageReference "iroh-ffi" */, 05A57C4D24BA3C92ED8757AB /* XCRemoteSwiftPackageReference "swift-sdk" */, TOML0001 /* XCRemoteSwiftPackageReference "swift-toml" */, ); @@ -1533,7 +1413,6 @@ A5001002 /* ContentView.swift in Sources */, B10A1CE6 /* RenderableSystemSymbol.swift in Sources */, NRSP0084A1B2C3D4E5F60719 /* WorkspaceSidebarModels.swift in Sources */, - NRSP0082A1B2C3D4E5F60719 /* WorkspaceRemoteModels.swift in Sources */, NRSP0080A1B2C3D4E5F60719 /* WorkspaceMountPresentation.swift in Sources */, NRSP0078A1B2C3D4E5F60719 /* Workspace+Theme.swift in Sources */, NRSP0076A1B2C3D4E5F60719 /* Workspace+Surfaces.swift in Sources */, @@ -1548,7 +1427,6 @@ CQTA000001 /* ClaudeQuotaMonitor.swift in Sources */, CQTA000003 /* SidebarQuotaFooter.swift in Sources */, LCMD000001 /* LastCommandOutcome.swift in Sources */, - NRSP0072A1B2C3D4E5F60719 /* Workspace+Remote.swift in Sources */, NRSP0070A1B2C3D4E5F60719 /* Workspace+FocusGeometry.swift in Sources */, NRSP0068A1B2C3D4E5F60719 /* WindowTerminalHostView.swift in Sources */, NRSP0066A1B2C3D4E5F60719 /* WindowOverlayControllers.swift in Sources */, @@ -1565,7 +1443,6 @@ CESH000001 /* session_escrow_shim.c in Sources */, NRSP0052A1B2C3D4E5F60719 /* TerminalCopyMode.swift in Sources */, NRSP0050A1B2C3D4E5F60719 /* SidebarShortcutHints.swift in Sources */, - NRSP0048A1B2C3D4E5F60719 /* SidebarRemoteErrorCopy.swift in Sources */, NRSP0046A1B2C3D4E5F60719 /* ProgramaSurfaceConfigTemplate.swift in Sources */, NRSP0020A1B2C3D4E5F60719 /* InternalTabDrag.swift in Sources */, NRSP0018A1B2C3D4E5F60719 /* GhosttyTerminalSupport.swift in Sources */, @@ -1608,20 +1485,6 @@ NRWS00000000000000000028 /* Workspace+Persistence.swift in Sources */, NRWS00000000000000000030 /* Workspace+Layout.swift in Sources */, NRWS00000000000000000032 /* Workspace+Bonsplit.swift in Sources */, - A5FF0012 /* WorkspaceRemoteSession.swift in Sources */, - NRWS00000000000000000018 /* WorkspaceRemoteSessionController+ConnectionOrchestration.swift in Sources */, - NRWS00000000000000000020 /* WorkspaceRemoteSessionController+ProcessExecution.swift in Sources */, - NRWS00000000000000000022 /* WorkspaceRemoteSessionController+DaemonInstall.swift in Sources */, - NRWS00000000000000000024 /* WorkspaceRemoteSessionController+ScriptBuilders.swift in Sources */, - NRWS00000000000000000026 /* WorkspaceRemoteSessionController+PortScanning.swift in Sources */, - NRWS00000000000000000006 /* WorkspaceRemoteDaemonPendingCallRegistry.swift in Sources */, - NRWS00000000000000000008 /* WorkspaceRemoteSSHBatchCommandBuilder.swift in Sources */, - NRWS00000000000000000010 /* WorkspaceRemoteDaemonRPCClient.swift in Sources */, - NRWS00000000000000000012 /* WorkspaceRemoteLoopbackHTTPRewriting.swift in Sources */, - NRWS00000000000000000014 /* WorkspaceRemoteProxyBroker.swift in Sources */, - NRWS00000000000000000016 /* WorkspaceRemoteCLIRelayServer.swift in Sources */, - NRWS00000000000000000002 /* RemoteSSHConnectionPolicy.swift in Sources */, - NRWS00000000000000000004 /* RemoteSCPUpload.swift in Sources */, A5001407 /* WorkspaceContentView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, @@ -1657,10 +1520,8 @@ NRTC0020 /* TerminalController+Subscriptions.swift in Sources */, NRTC0023 /* TerminalController+AgentPrompt.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, - A5001542 /* TerminalImageTransfer.swift in Sources */, - A5001543 /* TerminalSSHSessionDetector.swift in Sources */, + A5001542 /* TerminalPasteboardPlanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, - A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, E3E96C3E34893DE5905D6218 /* SessionAutosaveCoordinator.swift in Sources */, 5B5711B8348F309563272B9D /* ShortcutRouting.swift in Sources */, @@ -1668,7 +1529,6 @@ NRAD1002 /* TypingProfiler.swift in Sources */, 704EC7ED55706CC3099E0ED9 /* DurationSamples.swift in Sources */, NRAD1003 /* TerminalDirectoryOpener.swift in Sources */, - NRAD1004 /* VSCodeIntegration.swift in Sources */, NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */, NRAD1006 /* CLIInstaller.swift in Sources */, NRAD1007 /* MenuBarIconRenderer.swift in Sources */, @@ -1701,17 +1561,13 @@ NRBR0001 /* InspectorDock.swift in Sources */, NRBR0003 /* BrowserWebDialogPresenter.swift in Sources */, NRBR0005 /* BrowserProfileStore.swift in Sources */, - 1MPW0001 /* BrowserImportWizardView.swift in Sources */, + BA000001A1B2C3D4E5F60720 /* BrowserAvailability.swift in Sources */, NRBR0007 /* BrowserHistoryStore.swift in Sources */, NRBR0009 /* BrowserDownloadDelegate.swift in Sources */, NRBR0011 /* BrowserPanelWebDelegates.swift in Sources */, NRBR0013 /* BrowserUserProxySettings.swift in Sources */, NRBR0015 /* IMECompositionMessageHandler.swift in Sources */, - NRBR0019 /* BrowserExtensionManager.swift in Sources */, - NRBR0021 /* BrowserExtensionAdapters.swift in Sources */, NRBR0017 /* WebViewRepresentable.swift in Sources */, - A5FF0013 /* BrowserDataImport.swift in Sources */, - A500RG01 /* ReactGrab.swift in Sources */, DSGN000001 /* DesignMode.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, @@ -1744,18 +1600,11 @@ A5001208 /* UpdateTitlebarAccessory.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, A5001610 /* SessionPersistence.swift in Sources */, - A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* ProgramaConfig.swift in Sources */, A5001652 /* ProgramaConfigExecutor.swift in Sources */, NRPA00001 /* FileWatcher.swift in Sources */, A5001654 /* ProgramaDirectoryTrust.swift in Sources */, A5001660 /* JSONCParser.swift in Sources */, - MOBB0001 /* MobileBridgeSettings.swift in Sources */, - MOBB0003 /* MobileBridgeStreamSupport.swift in Sources */, - MOBB0005 /* MobileBridgeListener.swift in Sources */, - MOBB0007 /* MobileBridgeSession.swift in Sources */, - MOBB0009 /* MobileBridgePush.swift in Sources */, - MOBB0011 /* MobileBridgePairingCode.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1784,7 +1633,6 @@ E6FA9084A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift in Sources */, D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */, D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */, - FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */, C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */, AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */, @@ -1802,11 +1650,8 @@ RRPL0002A1B2C3D4E5F60718 /* RendererRealizationPlannerTests.swift in Sources */, 3C03A46E60CFD11D198C9504 /* SessionAutosaveCoordinatorTests.swift in Sources */, SWCT000001A1B2C3D4E5F607 /* SessionWALCoreTests.swift in Sources */, - FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, E57B23C339CE9F1226BC4478 /* ShortcutRoutingTests.swift in Sources */, - F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, - A9D4C7E2B16F4830C5A70212 /* MobileBridgeConnectionRegistryTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, @@ -1841,7 +1686,6 @@ 7C135CU00000000000000004 /* ClosedTerminalUndoStoreTests.swift in Sources */, 7C10SE2A0000000000000002 /* WorkspaceCloseConfirmationTests.swift in Sources */, C1A2B3C4D5E6F70800000001 /* ProgramaConfigTests.swift in Sources */, - C1A2B3C4D5E6F70800000006 /* ServeWebPortStoreTests.swift in Sources */, 9EAF4699346282F2171FCDB7 /* ProgramaDirectoryTrustTests.swift in Sources */, F6D8BDB710C64A444F3DE4F0 /* MCPSocketBridgeTests.swift in Sources */, 386AE1E1270DD8B6AA427C61 /* SocketPathResolution.swift in Sources */, @@ -1854,11 +1698,9 @@ buildActionMask = 2147483647; files = ( B9000002A1B2C3D4E5F60719 /* programa.swift in Sources */, - B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, B9000031A1B2C3D4E5F60719 /* CLI+Markdown.swift in Sources */, RVPN00000000000000000010 /* CLI+Review.swift in Sources */, RCAP000001 /* CLI+Recap.swift in Sources */, - B9000033A1B2C3D4E5F60719 /* CLI+SSH.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */, B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */, THTM0003 /* TerminalThemeStore.swift in Sources */, @@ -2297,14 +2139,6 @@ isa = XCLocalSwiftPackageReference; relativePath = vendor/bonsplit; }; - MOBB1001 /* XCRemoteSwiftPackageReference "iroh-ffi" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/manaflow-ai/iroh-ffi.git"; - requirement = { - kind = exactVersion; - version = "1.0.2-cmux.3"; - }; - }; 05A57C4D24BA3C92ED8757AB /* XCRemoteSwiftPackageReference "swift-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk"; @@ -2339,11 +2173,6 @@ package = A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; - MOBB1002 /* IrohLib */ = { - isa = XCSwiftPackageProductDependency; - package = MOBB1001 /* XCRemoteSwiftPackageReference "iroh-ffi" */; - productName = IrohLib; - }; 8B642EDBCCB415DF7E33F5C4 /* MCP */ = { isa = XCSwiftPackageProductDependency; package = 05A57C4D24BA3C92ED8757AB /* XCRemoteSwiftPackageReference "swift-sdk" */; diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f8059dea..58fd056a 100644 --- a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -10,15 +10,6 @@ "version" : "1.4.2" } }, - { - "identity" : "iroh-ffi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/manaflow-ai/iroh-ffi.git", - "state" : { - "revision" : "6710783b85780186e8733bc016868a5867356275", - "version" : "1.0.2-cmux.3" - } - }, { "identity" : "networkimage", "kind" : "remoteSourceControl", diff --git a/README.md b/README.md index a7ae7b54..38c34525 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Run many coding agents in parallel and always know which one needs you. - **In-app browser.** Split a scriptable browser next to your terminal; agents can snapshot the page, click, fill forms, and evaluate JS against your dev server. - **Native and fast.** Swift/AppKit with libghostty rendering, no Electron. Reads your existing `~/.config/ghostty/config` for themes, fonts, and colors. -More: named layouts (`programa layout save/apply`), SSH workspaces where browser panes route through the remote network, a markdown viewer panel, instant agent splits (⌘D / ⌘⇧D, ⌘⇧C for Claude Code), the command palette (⌘⇧P), and a CLI plus Unix-socket JSON-RPC API scriptable end to end. +More: named layouts (`programa layout save/apply`), a markdown viewer panel, instant agent splits (⌘D / ⌘⇧D, ⌘⇧C for Claude Code), the command palette (⌘⇧P), and a CLI plus Unix-socket JSON-RPC API scriptable end to end. ## Install @@ -47,7 +47,7 @@ brew tap darkroomengineering/programa brew install --cask programa ``` -Programa auto-updates: every commit on `main` that passes CI ships automatically as the latest release. On relaunch it restores layout, directories, scrollback, and browser state. Live processes don't survive a relaunch yet. +Programa auto-updates: every commit on `main` that passes CI ships automatically as the latest release. On relaunch it restores layout, directories, scrollback, and browser state. Terminal processes survive Programa quitting or crashing, and the app reattaches to them live on the next launch. ## Why @@ -57,7 +57,7 @@ Programa is a terminal, a browser, notifications, workspaces, and a CLI to contr ## Shortcuts -⌘⇧P opens the command palette, which lists every action. Full reference: [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md). Everything is editable in `Settings → Keyboard Shortcuts`. +⌘⇧P opens the command palette, which lists every action. Full reference: [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md). Everything is editable in `Settings → Keyboard Shortcuts`. Every other preference has a key in `~/.config/programa/settings.json`, documented in [docs/settings-json.md](docs/settings-json.md). ## Terminal themes diff --git a/Resources/Info.plist b/Resources/Info.plist index f6f1c352..b9dbfb03 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -49,10 +49,6 @@ <string>A program running within Programa would like to use your camera.</string> <key>NSPrincipalClass</key> <string>NSApplication</string> - <key>NSAppleScriptEnabled</key> - <true/> - <key>OSAScriptingDefinition</key> - <string>programa.sdef</string> <key>NSServices</key> <array> <dict> diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index afde7bf3..cf3f5cc2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -2725,127 +2725,6 @@ } } }, - "applescript.error.disabled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "AppleScript is disabled by the macos-applescript configuration." - } - } - } - }, - "applescript.error.failedToCreateSplit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to create split." - } - } - } - }, - "applescript.error.failedToCreateWindow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to create window." - } - } - } - }, - "applescript.error.failedToCreateWorkspace": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to create workspace." - } - } - } - }, - "applescript.error.missingAction": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Missing action string." - } - } - } - }, - "applescript.error.missingInputText": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Missing input text." - } - } - } - }, - "applescript.error.missingSplitDirection": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Missing or unknown split direction." - } - } - } - }, - "applescript.error.missingTerminalTarget": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Missing terminal target." - } - } - } - }, - "applescript.error.terminalUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Terminal is no longer available." - } - } - } - }, - "applescript.error.windowUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Window is no longer available." - } - } - } - }, - "applescript.error.workspaceUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace is no longer available." - } - } - } - }, "barBackground (tab chrome)": { "extractionState": "manual", "localizations": { @@ -3072,90 +2951,6 @@ } } }, - "browser.extensions.change": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Change Access" } }, - "ja": { "stringUnit": { "state": "translated", "value": "アクセスを変更" } } - } - }, - "browser.extensions.consent.message": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Version: %@\n\nPermissions:\n• %@\n\nWebsite access:\n• %@" } }, - "ja": { "stringUnit": { "state": "translated", "value": "バージョン:%@\n\n権限:\n• %@\n\nWebサイトへのアクセス:\n• %@" } } - } - }, - "browser.extensions.consent.title": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Enable %@?" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@を有効にしますか?" } } - } - }, - "browser.extensions.disabled": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Disabled" } }, - "ja": { "stringUnit": { "state": "translated", "value": "無効" } } - } - }, - "browser.extensions.enable": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Enable" } }, - "ja": { "stringUnit": { "state": "translated", "value": "有効にする" } } - } - }, - "browser.extensions.enabled": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Enabled" } }, - "ja": { "stringUnit": { "state": "translated", "value": "有効" } } - } - }, - "browser.extensions.manage": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Manage Browser Extensions" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ブラウザ拡張機能を管理" } } - } - }, - "browser.extensions.manage.message": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Select an extension to enable it or revoke its access." } }, - "ja": { "stringUnit": { "state": "translated", "value": "有効化またはアクセス権を取り消す拡張機能を選択してください。" } } - } - }, - "browser.extensions.manage.title": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Manage Browser Extensions" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ブラウザ拡張機能を管理" } } - } - }, - "browser.extensions.none.message": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Add an unpacked extension or ZIP archive to ~/.config/programa/extensions/." } }, - "ja": { "stringUnit": { "state": "translated", "value": "展開済みの拡張機能またはZIPアーカイブを ~/.config/programa/extensions/ に追加してください。" } } - } - }, - "browser.extensions.none.title": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "No Browser Extensions" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ブラウザ拡張機能がありません" } } - } - }, - "browser.extensions.noneRequested": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "None" } }, - "ja": { "stringUnit": { "state": "translated", "value": "なし" } } - } - }, "browser.goBack": { "extractionState": "manual", "localizations": { @@ -3178,1435 +2973,1478 @@ } } }, - "browser.import.additionalData": { + "browser.newTab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Additional data (bookmarks, settings, extensions)" + "value": "New tab" } } } }, - "browser.import.additionalData.note": { + "browser.omnibar.accessibilityLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Bookmarks, settings, and extensions are not available yet." + "value": "Browser omnibar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザのアドレスバー" } } } }, - "browser.import.back": { + "browser.openInDefaultBrowser": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Back" + "value": "Open in Default Browser" } } } }, - "browser.import.complete.browser": { + "browser.passkeyHandoff.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser: %@" + "value": "This page is asking for a passkey, which the built-in browser can't provide. You can finish signing in with your default browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このページはパスキーを要求していますが、内蔵ブラウザでは利用できません。既定のブラウザでサインインを完了できます。" } } } }, - "browser.import.complete.createdProfiles": { + "browser.passkeyHandoff.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Created Programa profiles: %@" + "value": "Passkeys aren't available in the built-in browser yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスキーは内蔵ブラウザではまだ利用できません" } } } }, - "browser.import.complete.destinationProfile": { + "browser.popup.loadingTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Destination profile: %@" + "value": "Loading…" } } } }, - "browser.import.complete.domainFilter": { + "browser.profile.buttonHelp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Domain filter: %@" + "value": "Browser Profile: %@" } } } }, - "browser.import.complete.importedCookies": { + "browser.profile.default": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Imported cookies: %ld" + "value": "Default" } } } }, - "browser.import.complete.importedHistory": { + "browser.profile.menu.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Imported history entries: %ld" + "value": "Profiles" } } } }, - "browser.import.complete.profileMapping": { + "browser.profile.new": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ -> %@" + "value": "New Profile..." } } } }, - "browser.import.complete.profileMappings": { + "browser.profile.new.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Profile mappings:" + "value": "Create a separate browser profile for cookies, history, and local storage." } } } }, - "browser.import.complete.scope": { + "browser.profile.new.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Scope: %@" + "value": "Profile name" } } } }, - "browser.import.complete.skippedCookies": { + "browser.profile.new.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Skipped cookies: %ld" + "value": "New Browser Profile" } } } }, - "browser.import.complete.sourceProfiles": { + "browser.profile.rename": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Source profiles: %@" + "value": "Rename Current Profile..." } } } }, - "browser.import.complete.title": { + "browser.profile.rename.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser data import complete" + "value": "Choose a new name for this browser profile." } } } }, - "browser.import.complete.warnings": { + "browser.profile.rename.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Warnings:" + "value": "Rename Browser Profile" } } } }, - "browser.import.cookies": { + "browser.reload": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cookies (site sign-ins)" + "value": "Reload" } } } }, - "browser.import.destination.cmux": { + "browser.stop": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Destination" + "value": "Stop" } } } }, - "browser.import.destinationMode.merge": { + "browser.switchToTab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Merge into one" + "value": "Switch to tab" } } } }, - "browser.import.destinationMode.separate": { + "browser.theme.buttonHelp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Separate profiles" + "value": "Browser Theme: %@" } } } }, - "browser.import.destinationProfile": { + "browser.toggleDevTools": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import into" + "value": "Toggle Developer Tools" } } } }, - "browser.import.destinationProfile.create": { + "cli.claude-teams.usage": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Create \"%@\"" + "value": "Usage: programa claude-teams [claude-args...]\n\nLaunch Claude Code with agent teams enabled.\n\nThis command:\n - defaults Claude teammate mode to auto\n - sets a tmux-like environment so Claude can open Programa helper workspaces\n - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to claude\n\nThe tmux shim turns supported tmux window/pane commands into nested helper\nworkspaces in the current Programa window.\n\nExamples:\n programa claude-teams\n programa claude-teams --continue\n programa claude-teams --model sonnet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法: programa claude-teams [claude-args...]\n\nエージェントチームを有効にして Claude Code を起動します。\n\nこのコマンドは次の処理を行います:\n - Claude のチームメイトモードをデフォルトで auto に設定\n - Claude が Programa のヘルパーワークスペースを開ける tmux 互換環境を設定\n - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 を設定\n - 専用の tmux シムを PATH の先頭に追加\n - 残りの引数をすべて claude に渡す\n\ntmux シムは対応する tmux の window/pane コマンドを、現在の Programa\nウインドウ内にあるネストされたヘルパーワークスペースへ変換します。\n\n例:\n programa claude-teams\n programa claude-teams --continue\n programa claude-teams --model sonnet" } } } }, - "browser.import.destinationProfile.mergeHelp": { + "cli.install.adminRequired": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "All selected source profiles go into one Programa profile." + "value": "Administrator privileges were required to write to /usr/local/bin." } } } }, - "browser.import.destinationProfile.separateHelp": { + "cli.install.symlinkCreated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Missing Programa profiles are created on import." + "value": "Created symlink:\n\n%1$@ -> %2$@" } } } }, - "browser.import.detected.all": { + "cli.installFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Detected: %@." + "value": "Couldn't Install Programa CLI" } } } }, - "browser.import.detected.more.one": { + "cli.installed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Detected: %@, +1 more." + "value": "Programa CLI Installed" } } } }, - "browser.import.detected.more.other": { + "cli.omo.usage": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Detected: %@, +%ld more." + "value": "Usage: programa omo [opencode-args...]\n\nLaunch OpenCode with oh-my-openagent in a Programa-aware environment.\n\noh-my-openagent orchestrates multiple AI models as specialized agents in\nparallel. This command sets up a tmux shim so agent panes become native\nPrograma splits with sidebar metadata and notifications.\n\nThis command:\n - sets a tmux-like environment so oh-my-openagent uses Programa splits\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to opencode\n\nThe tmux shim translates tmux window/pane commands into Programa workspace\nand split operations in the current Programa session.\n\nExamples:\n programa omo\n programa omo --continue\n programa omo --model claude-sonnet-4-6" } } } }, - "browser.import.detected.none": { + "cli.uninstall.adminRequired": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No supported browsers detected." + "value": "Administrator privileges were required to modify /usr/local/bin." } } } }, - "browser.import.domain": { + "cli.uninstall.notFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Domains" + "value": "No Programa CLI symlink was found at %@." } } } }, - "browser.import.domain.placeholder": { + "cli.uninstall.removed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Optional domains, comma-separated" + "value": "Removed %@." } } } }, - "browser.import.error.destinationCreateFailed": { + "cli.uninstallFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Programa could not create the destination profile \"%@\"." + "value": "Couldn't Uninstall Programa CLI" } } } }, - "browser.import.error.destinationMissing": { + "cli.uninstalled": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The selected Programa browser profile no longer exists. Pick a destination profile again." + "value": "Programa CLI Uninstalled" } } } }, - "browser.import.error.title": { + "command.applyLayout.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import could not start" + "value": "Layout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイアウト" } } } }, - "browser.import.hint.dismiss": { + "command.applyLayout.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide Hint" + "value": "Apply layout: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイアウトを適用: %@" } } } }, - "browser.import.hint.import": { + "command.applyUpdateIfAvailable.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import…" + "value": "Global" } } } }, - "browser.import.hint.settings": { + "command.applyUpdateIfAvailable.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Settings" + "value": "Apply Update (If Available)" } } } }, - "browser.import.hint.settingsFootnote": { + "command.attemptUpdate.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You can always find this in Settings > Browser." + "value": "Global" } } } }, - "browser.import.hint.title": { + "command.attemptUpdate.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import browser data" + "value": "Attempt Update" } } } }, - "browser.import.hint.toolbar": { + "command.browserBack.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import" + "value": "Back" } } } }, - "browser.import.hint.toolbar.help": { + "command.browserClearHistory.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import browser data" + "value": "Browser" } } } }, - "browser.import.history": { + "command.browserClearHistory.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "History (visited pages)" + "value": "Clear Browser History" } } } }, - "browser.import.next": { + "command.browserConsole.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next" + "value": "Show JavaScript Console" } } } }, - "browser.import.noBrowsers.message": { + "command.browserDesignMode.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Programa could not find browser profiles to import from on this Mac." + "value": "Toggle Design Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインモードを切り替え" } } } }, - "browser.import.noBrowsers.title": { + "command.browserDuplicateRight.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No importable browsers found" + "value": "Browser Layout" } } } }, - "browser.import.progress.message": { + "command.browserDuplicateRight.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Importing %@ from %@…" + "value": "Duplicate Browser to the Right" } } } }, - "browser.import.progress.subtitle": { + "command.browserFocusAddressBar.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This can take a few seconds for large profiles." + "value": "Focus Address Bar" } } } }, - "browser.import.progress.title": { + "command.browserForward.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Importing Browser Data" + "value": "Forward" } } } }, - "browser.import.scope.cookiesAndHistory": { + "command.browserOpenDefault.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cookies + history" + "value": "Open Current Page in Default Browser" } } } }, - "browser.import.scope.cookiesOnly": { + "command.browserReload.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cookies only" + "value": "Reload Page" } } } }, - "browser.import.scope.everything": { + "command.browserSplitDown.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Everything" + "value": "Browser Layout" } } } }, - "browser.import.scope.historyOnly": { + "command.browserSplitDown.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "History only" + "value": "Split Browser Down" } } } }, - "browser.import.source": { + "command.browserSplitRight.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser" + "value": "Browser Layout" } } } }, - "browser.import.sourceProfile.fallback": { + "command.browserSplitRight.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Profile %ld" + "value": "Split Browser Right" } } } }, - "browser.import.sourceProfiles": { + "command.browserToggleDevTools.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Profiles" + "value": "Toggle Developer Tools" } } } }, - "browser.import.sourceProfiles.empty": { + "command.browserZoomIn.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No source profiles detected for %@." + "value": "Zoom In" } } } }, - "browser.import.sourceProfiles.help": { + "command.browserZoomOut.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Select one or more profiles." + "value": "Zoom Out" } } } }, - "browser.import.start": { + "command.browserZoomReset.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Start Import" + "value": "Actual Size" } } } }, - "browser.import.step.dataTypes": { + "command.checkForUpdates.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Step 3 of 3" + "value": "Global" } } } }, - "browser.import.step.source": { + "command.checkForUpdates.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Step 1 of 3" + "value": "Check for Updates" } } } }, - "browser.import.step.sourceProfiles": { + "command.clearTabName.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Step 2 of 3" + "value": "Clear Tab Name" } } } }, - "browser.import.title": { + "command.clearWorkspaceDescription.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import Browser Data" + "value": "Clear Workspace Description" } } } }, - "browser.import.validation.scope": { + "command.clearWorkspaceName.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Select Cookies, History, or both before starting import." + "value": "Clear Workspace Name" } } } }, - "browser.import.validation.sourceProfiles": { + "command.closeTab.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose at least one source profile to import." + "value": "Tab" } } } }, - "browser.import.warning.additionalDataUnavailable": { + "command.closeTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." + "value": "Close Tab" } } } }, - "browser.import.warning.browserCookiesReadFailed": { + "command.closeWindow.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed reading %@ cookies at %@: %@" + "value": "Window" } } } }, - "browser.import.warning.browserHistoryReadFailed": { + "command.closeWindow.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed reading %@ history at %@: %@" + "value": "Close Window" } } } }, - "browser.import.warning.cookieImportUnsupported": { + "command.closeWorkspace.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ cookie import is not implemented yet." + "value": "Workspace" } } } }, - "browser.import.warning.encryptedCookiesSkipped": { + "command.closeWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Skipped %ld encrypted cookies that require Keychain decryption." + "value": "Close Workspace" } } } }, - "browser.import.warning.firefoxCookiesReadFailed": { + "command.cmuxConfig.customTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed reading Firefox cookies at %@: %@" + "value": "Custom: %@" } } } }, - "browser.import.warning.firefoxHistoryReadFailed": { + "command.cmuxConfig.recipeTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed reading Firefox history at %@: %@" + "value": "Recipe: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レシピ: %@" } } } }, - "browser.import.warning.keychainDecryptFailed": { + "command.cmuxConfig.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." + "value": "programa.json" } } } }, - "browser.import.warning.noHistoryDatabase": { + "command.disableMinimalMode.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No history database found for %@." + "value": "Disable Minimal Mode" } } } }, - "browser.import.warning.safariCookiesUnsupported": { + "command.editWorkspaceDescription.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." + "value": "Edit Workspace Description…" } } } }, - "browser.newTab": { + "command.enableMinimalMode.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New tab" + "value": "Enable Minimal Mode" } } } }, - "browser.omnibar.accessibilityLabel": { + "command.equalizeSplits.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser omnibar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブラウザのアドレスバー" + "value": "Equalize Splits" } } } }, - "browser.openInDefaultBrowser": { + "command.installCLI.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open in Default Browser" + "value": "CLI" } } } }, - "browser.passkeyHandoff.message": { + "command.installCLI.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This page is asking for a passkey, which the built-in browser can't provide. You can finish signing in with your default browser." + "value": "Shell Command: Install 'programa' in PATH" } - }, - "ja": { + } + } + }, + "command.jumpUnread.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "このページはパスキーを要求していますが、内蔵ブラウザでは利用できません。既定のブラウザでサインインを完了できます。" + "value": "Notifications" } } } }, - "browser.passkeyHandoff.title": { + "command.jumpUnread.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Passkeys aren't available in the built-in browser yet" + "value": "Jump to Latest Unread" } - }, - "ja": { + } + } + }, + "command.markTabRead.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "パスキーは内蔵ブラウザではまだ利用できません" + "value": "Mark Tab as Read" } } } }, - "browser.popup.loadingTitle": { + "command.markTabUnread.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Loading…" + "value": "Mark Tab as Unread" } } } }, - "browser.profile.buttonHelp": { + "command.newBrowserTab.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Profile: %@" + "value": "Tab" } } } }, - "browser.profile.default": { + "command.newBrowserTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Default" + "value": "New Tab (Browser)" } } } }, - "browser.profile.menu.title": { + "command.newClaudeWorkspace.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Profiles" + "value": "Open a new workspace running Claude Code in the current project" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のプロジェクトで Claude Code を起動する新しいワークスペースを開く" } } } }, - "browser.profile.new": { + "command.newClaudeWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Profile..." + "value": "New Claude Code Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規 Claude Code ワークスペース" } } } }, - "browser.profile.new.message": { + "command.newTerminalTab.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Create a separate browser profile for cookies, history, and local storage." + "value": "Tab" } } } }, - "browser.profile.new.placeholder": { + "command.newTerminalTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Profile name" + "value": "New Tab (Terminal)" } } } }, - "browser.profile.new.title": { + "command.newWindow.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Browser Profile" + "value": "Window" } } } }, - "browser.profile.rename": { + "command.newWindow.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename Current Profile..." + "value": "New Window" } } } }, - "browser.profile.rename.message": { + "command.newWorkspace.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose a new name for this browser profile." + "value": "Workspace" } } } }, - "browser.profile.rename.title": { + "command.newWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename Browser Profile" + "value": "New Workspace" } } } }, - "browser.reactGrab": { + "command.nextTabInPane.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Inject React Grab" + "value": "Tab Navigation" } } } }, - "browser.reload": { + "command.nextTabInPane.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reload" + "value": "Next Tab in Pane" } } } }, - "browser.stop": { + "command.nextWorkspace.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Stop" + "value": "Workspace Navigation" } } } }, - "browser.switchToTab": { + "command.nextWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Switch to tab" + "value": "Next Workspace" } } } }, - "browser.theme.buttonHelp": { + "command.openFolder.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Theme: %@" + "value": "Workspace" } } } }, - "browser.toggleDevTools": { + "command.openFolder.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Toggle Developer Tools" + "value": "Open Folder…" } } } }, - "cli.claude-teams.usage": { + "command.openReviewPanel.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Usage: programa claude-teams [claude-args...]\n\nLaunch Claude Code with agent teams enabled.\n\nThis command:\n - defaults Claude teammate mode to auto\n - sets a tmux-like environment so Claude can open Programa helper workspaces\n - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to claude\n\nThe tmux shim turns supported tmux window/pane commands into nested helper\nworkspaces in the current Programa window.\n\nExamples:\n programa claude-teams\n programa claude-teams --continue\n programa claude-teams --model sonnet" + "value": "Diff of the focused terminal's repo" } }, "ja": { "stringUnit": { "state": "translated", - "value": "使用方法: programa claude-teams [claude-args...]\n\nエージェントチームを有効にして Claude Code を起動します。\n\nこのコマンドは次の処理を行います:\n - Claude のチームメイトモードをデフォルトで auto に設定\n - Claude が Programa のヘルパーワークスペースを開ける tmux 互換環境を設定\n - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 を設定\n - 専用の tmux シムを PATH の先頭に追加\n - 残りの引数をすべて claude に渡す\n\ntmux シムは対応する tmux の window/pane コマンドを、現在の Programa\nウインドウ内にあるネストされたヘルパーワークスペースへ変換します。\n\n例:\n programa claude-teams\n programa claude-teams --continue\n programa claude-teams --model sonnet" + "value": "フォーカス中ターミナルのリポジトリの差分" } } } }, - "cli.install.adminRequired": { + "command.openReviewPanel.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Administrator privileges were required to write to /usr/local/bin." + "value": "Open Review Panel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レビューパネルを開く" } } } }, - "cli.install.symlinkCreated": { + "command.openSettings.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Created symlink:\n\n%1$@ -> %2$@" + "value": "Global" } } } }, - "cli.installFailed": { + "command.openSettings.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't Install Programa CLI" + "value": "Open Settings" } } } }, - "cli.installed": { + "command.openWorkspacePRLinks.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Programa CLI Installed" + "value": "Open All Workspace PR Links" } } } }, - "cli.omo.usage": { + "command.pinTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Usage: programa omo [opencode-args...]\n\nLaunch OpenCode with oh-my-openagent in a Programa-aware environment.\n\noh-my-openagent orchestrates multiple AI models as specialized agents in\nparallel. This command sets up a tmux shim so agent panes become native\nPrograma splits with sidebar metadata and notifications.\n\nThis command:\n - sets a tmux-like environment so oh-my-openagent uses Programa splits\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to opencode\n\nThe tmux shim translates tmux window/pane commands into Programa workspace\nand split operations in the current Programa session.\n\nExamples:\n programa omo\n programa omo --continue\n programa omo --model claude-sonnet-4-6" + "value": "Pin Tab" } } } }, - "cli.uninstall.adminRequired": { + "command.pinWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Administrator privileges were required to modify /usr/local/bin." + "value": "Pin Workspace" } } } }, - "cli.uninstall.notFound": { + "command.previousTabInPane.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No Programa CLI symlink was found at %@." + "value": "Tab Navigation" } } } }, - "cli.uninstall.removed": { + "command.previousTabInPane.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Removed %@." - } - } - } - }, - "cli.uninstallFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Couldn't Uninstall Programa CLI" - } - } - } - }, - "cli.uninstalled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Programa CLI Uninstalled" + "value": "Previous Tab in Pane" } } } }, - "clipboard.sshError.item": { + "command.previousWorkspace.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%lld. %@ (%@): %@" + "value": "Workspace Navigation" } } } }, - "clipboard.sshError.single": { + "command.previousWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "SSH error (%@): %@" + "value": "Previous Workspace" } } } }, - "command.applyLayout.subtitle": { + "command.reloadConfiguration.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Layout" + "value": "Re-read settings.json and programa.json" } }, "ja": { "stringUnit": { "state": "translated", - "value": "レイアウト" + "value": "settings.json と programa.json を再読み込みします" } } } }, - "command.applyLayout.title": { + "command.reloadConfiguration.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Apply layout: %@" + "value": "Reload Configuration" } }, "ja": { "stringUnit": { "state": "translated", - "value": "レイアウトを適用: %@" + "value": "設定を再読み込み" } } } }, - "command.applyUpdateIfAvailable.subtitle": { + "command.renameTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Global" + "value": "Rename Tab…" } } } }, - "command.applyUpdateIfAvailable.title": { + "command.renameWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Apply Update (If Available)" + "value": "Rename Workspace…" } } } }, - "command.attemptUpdate.subtitle": { + "command.reopenClosedBrowserTab.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Global" + "value": "Browser" } } } }, - "command.attemptUpdate.title": { + "command.reopenClosedBrowserTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Attempt Update" + "value": "Reopen Closed Browser Tab" } } } }, - "command.browserBack.title": { + "command.restartSocketListener.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Back" + "value": "Global" } } } }, - "command.browserClearHistory.subtitle": { + "command.restartSocketListener.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser" + "value": "Restart CLI Listener" } } } }, - "command.browserClearHistory.title": { + "command.sendFeedback.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear Browser History" + "value": "Compose feedback to the programa team" } - } - } - }, - "command.browserConsole.title": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Show JavaScript Console" + "value": "programa チームにフィードバックを送る" } } } }, - "command.browserDesignMode.title": { + "command.sendFeedback.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Toggle Design Mode" + "value": "Send Feedback…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "デザインモードを切り替え" + "value": "フィードバックを送信…" } } } }, - "command.browserDuplicateRight.subtitle": { + "command.showNotifications.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Layout" + "value": "Notifications" } } } }, - "command.browserDuplicateRight.title": { + "command.showNotifications.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Duplicate Browser to the Right" + "value": "Show Notifications" } } } }, - "command.browserFocusAddressBar.title": { + "command.terminalFind.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Focus Address Bar" + "value": "Find…" } } } }, - "command.browserForward.title": { + "command.terminalFindNext.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Forward" + "value": "Find Next" } } } }, - "command.browserOpenDefault.title": { + "command.terminalFindPrevious.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Page in Default Browser" + "value": "Find Previous" } } } }, - "command.browserReload.title": { + "command.terminalHideFind.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reload Page" + "value": "Hide Find Bar" } } } }, - "command.browserSplitDown.subtitle": { + "command.terminalSplitBrowserDown.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Layout" + "value": "Terminal Layout" } } } }, - "command.browserSplitDown.title": { + "command.terminalSplitBrowserDown.title": { "extractionState": "manual", "localizations": { "en": { @@ -4617,18 +4455,18 @@ } } }, - "command.browserSplitRight.subtitle": { + "command.terminalSplitBrowserRight.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Layout" + "value": "Terminal Layout" } } } }, - "command.browserSplitRight.title": { + "command.terminalSplitBrowserRight.title": { "extractionState": "manual", "localizations": { "en": { @@ -4639,487 +4477,491 @@ } } }, - "command.browserToggleDevTools.title": { + "command.terminalSplitDown.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Toggle Developer Tools" + "value": "Terminal Layout" } } } }, - "command.browserZoomIn.title": { + "command.terminalSplitDown.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom In" + "value": "Split Down" } } } }, - "command.browserZoomOut.title": { + "command.terminalSplitRight.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom Out" + "value": "Terminal Layout" } } } }, - "command.browserZoomReset.title": { + "command.terminalSplitRight.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Actual Size" + "value": "Split Right" } } } }, - "command.checkForUpdates.subtitle": { + "command.terminalUseSelectionForFind.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Global" + "value": "Use Selection for Find" } } } }, - "command.checkForUpdates.title": { + "command.toggleFullScreen.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check for Updates" + "value": "Window" } } } }, - "command.clearTabName.title": { + "command.toggleFullScreen.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear Tab Name" + "value": "Toggle Full Screen" } } } }, - "command.clearWorkspaceDescription.title": { + "command.toggleSidebar.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear Workspace Description" + "value": "Layout" } } } }, - "command.clearWorkspaceName.title": { + "command.toggleSidebar.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear Workspace Name" + "value": "Toggle Sidebar" } } } }, - "command.closeTab.subtitle": { + "command.toggleSplitZoom.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "Terminal Layout" } } } }, - "command.closeTab.title": { + "command.toggleSplitZoom.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Tab" + "value": "Toggle Pane Zoom" } } } }, - "command.closeWindow.subtitle": { + "command.triggerFlash.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Window" + "value": "View" } } } }, - "command.closeWindow.title": { + "command.triggerFlash.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Window" + "value": "Flash Focused Panel" } } } }, - "command.closeWorkspace.subtitle": { + "command.uninstallCLI.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Workspace" + "value": "CLI" } } } }, - "command.closeWorkspace.title": { + "command.uninstallCLI.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Workspace" + "value": "Shell Command: Uninstall 'programa' from PATH" } } } }, - "command.cmuxConfig.customTitle": { + "command.unpinTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Custom: %@" + "value": "Unpin Tab" } } } }, - "command.cmuxConfig.recipeTitle": { + "command.unpinWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Recipe: %@" + "value": "Unpin Workspace" } - }, - "ja": { + } + } + }, + "commandPalette.description.workspaceInputHint": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "レシピ: %@" + "value": "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." } } } }, - "command.cmuxConfig.subtitle": { + "commandPalette.description.workspacePlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "programa.json" + "value": "Workspace description" } } } }, - "command.disableMinimalMode.title": { + "commandPalette.kind.browser": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Disable Minimal Mode" + "value": "Browser" } } } }, - "command.editWorkspaceDescription.title": { + "commandPalette.kind.markdown": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Edit Workspace Description…" + "value": "Markdown" } } } }, - "command.enableMinimalMode.title": { + "commandPalette.kind.terminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable Minimal Mode" + "value": "Terminal" } } } }, - "command.equalizeSplits.title": { + "commandPalette.kind.workspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Equalize Splits" + "value": "Workspace" } } } }, - "command.installCLI.subtitle": { + "commandPalette.rename.clearCustomName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "CLI" + "value": "(clear custom name)" } } } }, - "command.installCLI.title": { + "commandPalette.rename.tabConfirmHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Shell Command: Install 'programa' in PATH" + "value": "Press Enter to apply this tab name, or Escape to cancel." } } } }, - "command.jumpUnread.subtitle": { + "commandPalette.rename.tabDescription": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notifications" + "value": "Choose a custom tab name." } } } }, - "command.jumpUnread.title": { + "commandPalette.rename.tabInputHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jump to Latest Unread" + "value": "Enter a tab name. Press Enter to rename, Escape to cancel." } } } }, - "command.markTabRead.title": { + "commandPalette.rename.tabPlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mark Tab as Read" + "value": "Tab name" } } } }, - "command.markTabUnread.title": { + "commandPalette.rename.tabTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mark Tab as Unread" + "value": "Rename Tab" } } } }, - "command.newBrowserTab.subtitle": { + "commandPalette.rename.workspaceConfirmHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "Press Enter to apply this workspace name, or Escape to cancel." } } } }, - "command.newBrowserTab.title": { + "commandPalette.rename.workspaceDescription": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Tab (Browser)" + "value": "Choose a custom workspace name." } } } }, - "command.newClaudeWorkspace.subtitle": { + "commandPalette.rename.workspaceInputHint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open a new workspace running Claude Code in the current project" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現在のプロジェクトで Claude Code を起動する新しいワークスペースを開く" + "value": "Enter a workspace name. Press Enter to rename, Escape to cancel." } } } }, - "command.newClaudeWorkspace.title": { + "commandPalette.rename.workspacePlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Claude Code Workspace" + "value": "Workspace name" } - }, - "ja": { + } + } + }, + "commandPalette.rename.workspaceTitle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "新規 Claude Code ワークスペース" + "value": "Rename Workspace" } } } }, - "command.newTerminalTab.subtitle": { + "commandPalette.search.commandsEmpty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "No commands match your search." } } } }, - "command.newTerminalTab.title": { + "commandPalette.search.commandsPlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Tab (Terminal)" + "value": "Type a command" } } } }, - "command.newWindow.subtitle": { + "commandPalette.search.switcherEmpty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Window" + "value": "No workspaces match your search." } } } }, - "command.newWindow.title": { + "commandPalette.search.switcherEmptyAllSurfaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Window" + "value": "No workspaces or surfaces match your search." } } } }, - "command.newWorkspace.subtitle": { + "commandPalette.search.switcherPlaceholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Workspace" + "value": "Search workspaces" } } } }, - "command.newWorkspace.title": { + "commandPalette.search.switcherPlaceholderAllSurfaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Workspace" + "value": "Search workspaces and surfaces" } } } }, - "command.nextTabInPane.subtitle": { + "commandPalette.subtitle.browserWithName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab Navigation" + "value": "Browser • %@" } } } }, - "command.nextTabInPane.title": { + "commandPalette.subtitle.tabFallback": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next Tab in Pane" + "value": "Tab" } } } }, - "command.nextWorkspace.subtitle": { + "commandPalette.subtitle.tabWithName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Workspace Navigation" + "value": "Tab • %@" } } } }, - "command.nextWorkspace.title": { + "commandPalette.subtitle.terminalWithName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next Workspace" + "value": "Terminal • %@" } } } }, - "command.openFolder.subtitle": { + "commandPalette.subtitle.workspaceFallback": { "extractionState": "manual", "localizations": { "en": { @@ -5130,5875 +4972,4216 @@ } } }, - "command.openFolder.title": { + "commandPalette.subtitle.workspaceWithName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Folder…" + "value": "Workspace • %@" } } } }, - "command.openFolderInVSCodeInline.subtitle": { + "commandPalette.switcher.windowLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "VS Code Inline" + "value": "Window %lld" } } } }, - "command.openFolderInVSCodeInline.title": { + "commandPalette.switcher.workspaceLabel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Folder in VS Code (Inline)…" + "value": "Workspace" } } } }, - "command.openReviewPanel.subtitle": { + "common.allow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Diff of the focused terminal's repo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォーカス中ターミナルのリポジトリの差分" + "value": "Allow" } } } }, - "command.openReviewPanel.title": { + "common.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Review Panel" + "value": "Cancel" } }, "ja": { "stringUnit": { "state": "translated", - "value": "レビューパネルを開く" + "value": "キャンセル" } } } }, - "command.openSettings.subtitle": { + "common.close": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Global" + "value": "Close" } } } }, - "command.openSettings.title": { + "common.copyDetails": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Settings" + "value": "Copy Details" } } } }, - "command.openWorkspacePRLinks.title": { + "common.create": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open All Workspace PR Links" + "value": "Create" } } } }, - "command.pinTab.title": { + "common.installAndRelaunch": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pin Tab" + "value": "Install and Relaunch" } } } }, - "command.pinWorkspace.title": { + "common.later": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pin Workspace" + "value": "Later" } } } }, - "command.previousTabInPane.subtitle": { + "common.notNow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab Navigation" + "value": "Not Now" } } } }, - "command.previousTabInPane.title": { + "common.ok": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Previous Tab in Pane" + "value": "OK" } } } }, - "command.previousWorkspace.subtitle": { + "common.rename": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Workspace Navigation" + "value": "Rename" } } } }, - "command.previousWorkspace.title": { + "common.restartLater": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Previous Workspace" + "value": "Restart Later" } } } }, - "command.reloadConfiguration.subtitle": { + "common.restartNow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Re-read settings.json and programa.json" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "settings.json と programa.json を再読み込みします" + "value": "Restart Now" } } } }, - "command.reloadConfiguration.title": { + "common.retry": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reload Configuration" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定を再読み込み" + "value": "Retry" } } } }, - "command.renameTab.title": { + "common.skip": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename Tab…" + "value": "Skip" } } } }, - "command.renameWorkspace.title": { + "contextMenu.chooseCustomColor": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename Workspace…" + "value": "Choose Custom Color…" } } } }, - "command.reopenClosedBrowserTab.subtitle": { + "contextMenu.clearColor": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser" + "value": "Clear Color" } } } }, - "command.reopenClosedBrowserTab.title": { + "contextMenu.clearLatestNotification": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reopen Closed Browser Tab" + "value": "Clear Latest Notification" } } } }, - "command.restartSocketListener.subtitle": { + "contextMenu.clearLatestNotifications": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Global" + "value": "Clear Latest Notifications" } } } }, - "command.restartSocketListener.title": { + "contextMenu.clearWorkspaceDescription": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Restart CLI Listener" + "value": "Clear Workspace Description" } } } }, - "command.sendFeedback.subtitle": { + "contextMenu.closeOtherWorkspaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Compose feedback to the programa team" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "programa チームにフィードバックを送る" + "value": "Close Other Workspaces" } } } }, - "command.sendFeedback.title": { + "contextMenu.closeWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Send Feedback…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フィードバックを送信…" + "value": "Close Workspace" } } } }, - "command.showNotifications.subtitle": { + "contextMenu.closeWorkspaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notifications" + "value": "Close Workspaces" } } } }, - "command.showNotifications.title": { + "contextMenu.closeWorkspacesAbove": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Notifications" + "value": "Close Workspaces Above" } } } }, - "command.terminalFind.title": { + "contextMenu.closeWorkspacesBelow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find…" + "value": "Close Workspaces Below" } } } }, - "command.terminalFindNext.title": { + "contextMenu.editWorkspaceDescription": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find Next" + "value": "Edit Workspace Description…" } } } }, - "command.terminalFindPrevious.title": { + "contextMenu.markWorkspaceRead": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find Previous" + "value": "Mark Workspace as Read" } } } }, - "command.terminalHideFind.title": { + "contextMenu.markWorkspaceUnread": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide Find Bar" + "value": "Mark Workspace as Unread" } } } }, - "command.terminalSplitBrowserDown.subtitle": { + "contextMenu.markWorkspacesRead": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal Layout" + "value": "Mark Workspaces as Read" } } } }, - "command.terminalSplitBrowserDown.title": { + "contextMenu.markWorkspacesUnread": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Browser Down" + "value": "Mark Workspaces as Unread" } } } }, - "command.terminalSplitBrowserRight.subtitle": { + "contextMenu.collapseWorktreeFolder": { "extractionState": "manual", "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Terminal Layout" - } - } + "en": { "stringUnit": { "state": "translated", "value": "Collapse Worktree Folder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダを折りたたむ" } } } }, - "command.terminalSplitBrowserRight.title": { + "contextMenu.expandWorktreeFolder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Expand Worktree Folder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダを展開" } } + } + }, + "contextMenu.moveDown": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Browser Right" + "value": "Move Down" } } } }, - "command.terminalSplitDown.subtitle": { + "contextMenu.moveToTop": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal Layout" + "value": "Move to Top" } } } }, - "command.terminalSplitDown.title": { + "contextMenu.moveUp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Down" + "value": "Move Up" } } } }, - "command.terminalSplitRight.subtitle": { + "contextMenu.moveWorkspaceToWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal Layout" + "value": "Move Workspace to Window" } } } }, - "command.terminalSplitRight.title": { + "contextMenu.moveWorkspacesToWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Right" + "value": "Move Workspaces to Window" } } } }, - "command.terminalUseSelectionForFind.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Use Selection for Find" - } - } - } - }, - "command.toggleFullScreen.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Window" - } - } - } - }, - "command.toggleFullScreen.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle Full Screen" - } - } - } - }, - "command.toggleSidebar.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Layout" - } - } - } - }, - "command.toggleSidebar.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle Sidebar" - } - } - } - }, - "command.toggleSplitZoom.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Terminal Layout" - } - } - } - }, - "command.toggleSplitZoom.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle Pane Zoom" - } - } - } - }, - "command.triggerFlash.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "View" - } - } - } - }, - "command.triggerFlash.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Flash Focused Panel" - } - } - } - }, - "command.uninstallCLI.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "CLI" - } - } - } - }, - "command.uninstallCLI.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Shell Command: Uninstall 'programa' from PATH" - } - } - } - }, - "command.unpinTab.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unpin Tab" - } - } - } - }, - "command.unpinWorkspace.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unpin Workspace" - } - } - } - }, - "command.vscodeServeWebRestart.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart VS Code Inline Server" - } - } - } - }, - "command.vscodeServeWebStop.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop VS Code Inline Server" - } - } - } - }, - "commandPalette.description.workspaceInputHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." - } - } - } - }, - "commandPalette.description.workspacePlaceholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace description" - } - } - } - }, - "commandPalette.kind.browser": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser" - } - } - } - }, - "commandPalette.kind.markdown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Markdown" - } - } - } - }, - "commandPalette.kind.terminal": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Terminal" - } - } - } - }, - "commandPalette.kind.workspace": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace" - } - } - } - }, - "commandPalette.rename.clearCustomName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "(clear custom name)" - } - } - } - }, - "commandPalette.rename.tabConfirmHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Press Enter to apply this tab name, or Escape to cancel." - } - } - } - }, - "commandPalette.rename.tabDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a custom tab name." - } - } - } - }, - "commandPalette.rename.tabInputHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter a tab name. Press Enter to rename, Escape to cancel." - } - } - } - }, - "commandPalette.rename.tabPlaceholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tab name" - } - } - } - }, - "commandPalette.rename.tabTitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Tab" - } - } - } - }, - "commandPalette.rename.workspaceConfirmHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Press Enter to apply this workspace name, or Escape to cancel." - } - } - } - }, - "commandPalette.rename.workspaceDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a custom workspace name." - } - } - } - }, - "commandPalette.rename.workspaceInputHint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter a workspace name. Press Enter to rename, Escape to cancel." - } - } - } - }, - "commandPalette.rename.workspacePlaceholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace name" - } - } - } - }, - "commandPalette.rename.workspaceTitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Workspace" - } - } - } - }, - "commandPalette.search.commandsEmpty": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No commands match your search." - } - } - } - }, - "commandPalette.search.commandsPlaceholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Type a command" - } - } - } - }, - "commandPalette.search.switcherEmpty": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No workspaces match your search." - } - } - } - }, - "commandPalette.search.switcherEmptyAllSurfaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No workspaces or surfaces match your search." - } - } - } - }, - "commandPalette.search.switcherPlaceholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Search workspaces" - } - } - } - }, - "commandPalette.search.switcherPlaceholderAllSurfaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Search workspaces and surfaces" - } - } - } - }, - "commandPalette.subtitle.browserWithName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser • %@" - } - } - } - }, - "commandPalette.subtitle.tabFallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tab" - } - } - } - }, - "commandPalette.subtitle.tabWithName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tab • %@" - } - } - } - }, - "commandPalette.subtitle.terminalWithName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Terminal • %@" - } - } - } - }, - "commandPalette.subtitle.workspaceFallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace" - } - } - } - }, - "commandPalette.subtitle.workspaceWithName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace • %@" - } - } - } - }, - "commandPalette.switcher.windowLabel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Window %lld" - } - } - } - }, - "commandPalette.switcher.workspaceLabel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace" - } - } - } - }, - "common.allow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Allow" - } - } - } - }, - "common.cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - } - } - }, - "common.close": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close" - } - } - } - }, - "common.copyDetails": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy Details" - } - } - } - }, - "common.create": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Create" - } - } - } - }, - "common.installAndRelaunch": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Install and Relaunch" - } - } - } - }, - "common.later": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Later" - } - } - } - }, - "common.notNow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not Now" - } - } - } - }, - "common.ok": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "OK" - } - } - } - }, - "common.rename": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename" - } - } - } - }, - "common.restartLater": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart Later" - } - } - } - }, - "common.restartNow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart Now" - } - } - } - }, - "common.retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - } - } - }, - "common.skip": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Skip" - } - } - } - }, - "contextMenu.chooseCustomColor": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose Custom Color…" - } - } - } - }, - "contextMenu.clearColor": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear Color" - } - } - } - }, - "contextMenu.clearLatestNotification": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear Latest Notification" - } - } - } - }, - "contextMenu.clearLatestNotifications": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear Latest Notifications" - } - } - } - }, - "contextMenu.clearWorkspaceDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear Workspace Description" - } - } - } - }, - "contextMenu.closeOtherWorkspaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Other Workspaces" - } - } - } - }, - "contextMenu.closeWorkspace": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Workspace" - } - } - } - }, - "contextMenu.closeWorkspaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Workspaces" - } - } - } - }, - "contextMenu.closeWorkspacesAbove": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Workspaces Above" - } - } - } - }, - "contextMenu.closeWorkspacesBelow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Close Workspaces Below" - } - } - } - }, - "contextMenu.copyError": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy Error" - } - } - } - }, - "contextMenu.copyErrors": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy Errors" - } - } - } - }, - "contextMenu.copySshError": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy SSH Error" - } - } - } - }, - "contextMenu.editWorkspaceDescription": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit Workspace Description…" - } - } - } - }, - "contextMenu.markWorkspaceRead": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Mark Workspace as Read" - } - } - } - }, - "contextMenu.markWorkspaceUnread": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Mark Workspace as Unread" - } - } - } - }, - "contextMenu.markWorkspacesRead": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Mark Workspaces as Read" - } - } - } - }, - "contextMenu.markWorkspacesUnread": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Mark Workspaces as Unread" - } - } - } - }, - "contextMenu.collapseWorktreeFolder": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Collapse Worktree Folder" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダを折りたたむ" } } - } - }, - "contextMenu.expandWorktreeFolder": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Expand Worktree Folder" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダを展開" } } - } - }, - "contextMenu.moveDown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move Down" - } - } - } - }, - "contextMenu.moveToTop": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move to Top" - } - } - } - }, - "contextMenu.moveUp": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move Up" - } - } - } - }, - "contextMenu.moveWorkspaceToWindow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move Workspace to Window" - } - } - } - }, - "contextMenu.moveWorkspacesToWindow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Move Workspaces to Window" - } - } - } - }, - "contextMenu.newWorktreeWorkspace": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "New Worktree Workspace…" } }, - "ja": { "stringUnit": { "state": "translated", "value": "新しいワークツリーワークスペース…" } } - } - }, - "contextMenu.openAgentOverview": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Open Agent Overview" } }, - "ja": { "stringUnit": { "state": "translated", "value": "エージェント概要を開く" } } - } - }, - "contextMenu.newWindow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Window" - } - } - } - }, - "contextMenu.pinWorkspace": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Pin Workspace" - } - } - } - }, - "contextMenu.pinWorkspaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Pin Workspaces" - } - } - } - }, - "contextMenu.removeCustomWorkspaceName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove Custom Workspace Name" - } - } - } - }, - "contextMenu.renameWorkspace": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Rename Workspace…" - } - } - } - }, - "contextMenu.stopUsingAsWorktreeFolder": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Stop Using as Worktree Folder" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダとしての使用を停止" } } - } - }, - "contextMenu.unpinWorkspace": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unpin Workspace" - } - } - } - }, - "contextMenu.unpinWorkspaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unpin Workspaces" - } - } - } - }, - "contextMenu.useAsWorktreeFolder": { - "extractionState": "manual", - "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Use as Worktree Folder" } }, - "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダとして使用" } } - } - }, - "contextMenu.workspaceColor": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace Color" - } - } - } - }, - "controlBackgroundColor": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "controlBackgroundColor" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "controlBackgroundColor" - } - } - } - }, - "debug.browserToolbarGlass.enable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable Native Glass Browser Toolbar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネイティブガラスのブラウザツールバーを有効にする" - } - } - } - }, - "debug.browserToolbarGlass.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser Toolbar Glass" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブラウザツールバーガラス" - } - } - } - }, - "debug.browserProfilePopover.group.padding": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Padding" - } - } - } - }, - "debug.browserProfilePopover.group.preview": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Preview" - } - } - } - }, - "debug.browserProfilePopover.heading": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser Profile Popover" - } - } - } - }, - "debug.browserProfilePopover.label.horizontal": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Horizontal" - } - } - } - }, - "debug.browserProfilePopover.label.vertical": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Vertical" - } - } - } - }, - "debug.browserProfilePopover.liveNote": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Changes apply live to the browser profile popover." - } - } - } - }, - "debug.browserProfilePopover.note": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tune the profile popover padding live while comparing it against the browser toolbar menu." - } - } - } - }, - "debug.browserProfilePopover.reset": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset" - } - } - } - }, - "debug.devBuildBanner.show": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Show Dev Build Banner" - } - } - } - }, - "debug.devBuildBanner.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "THIS IS A DEV BUILD" - } - } - } - }, - "debug.menu.background": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Background Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "背景デバッグ…" - } - } - } - }, - "debug.menu.browserProfilePopoverDebug": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser Profile Popover Debug…" - } - } - } - }, - "debug.menu.browserToolbarGlass": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser Toolbar Glass Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブラウザツールバーガラスデバッグ…" - } - } - } - }, - "debug.menu.browserToolbarButtonSpacing": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Browser Toolbar Button Spacing" - } - } - } - }, - "debug.menu.menuBarExtra": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Menu Bar Extra Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メニューバー追加項目デバッグ…" - } - } - } - }, - "debug.menu.overlayGlass": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Overlay Glass Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "オーバーレイガラスデバッグ…" - } - } - } - }, - "debug.menu.newLargeScrollbackTab": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Tab With Large Scrollback" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "大きなスクロールバック付きの新規タブ" - } - } - } - }, - "debug.menu.newLoremTab": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Tab With Lorem Search Text" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Lorem検索テキスト付きの新規タブ" - } - } - } - }, - "debug.menu.openAllWindows": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open All Debug Windows" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべてのデバッグウインドウを開く" - } - } - } - }, - "debug.menu.openStressWorkspacesWithLoadedSurfaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Stress Workspaces and Load All Terminals" - } - } - } - }, - "debug.menu.openWorkspaceColors": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Workspaces for All Workspace Colors" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべてのワークスペース色を開く" - } - } - } - }, - "debug.menu.settingsAboutTitlebar": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings/About Titlebar Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定/情報タイトルバーデバッグ…" - } - } - } - }, - "debug.menu.sidebar": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sidebar Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "サイドバーデバッグ…" - } - } - } - }, - "debug.menu.splitButtonLayout": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Split Button Layout Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "分割ボタンレイアウトデバッグ…" - } - } - } - }, - "debug.overlayGlass.enable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable Native Glass Overlays" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネイティブガラスのオーバーレイを有効にする" - } - } - } - }, - "debug.overlayGlass.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Overlay Glass" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "オーバーレイガラス" - } - } - } - }, - "debug.menu.tabBarGlass": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tab Bar Glass Debug…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "タブバーガラスデバッグ…" - } - } - } - }, - "debug.tabBarGlass.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tab Bar Glass" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "タブバーガラス" - } - } - } - }, - "debug.tabBarGlass.enable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable Native Glass Tab Pills" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネイティブガラスのタブピルを有効にする" - } - } - } - }, - "debug.glass.changesApplyLive": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Changes apply live." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "変更はすぐに反映されます。" - } - } - } - }, - "debug.glass.requiresMacOS26": { + "contextMenu.newWorktreeWorkspace": { "extractionState": "manual", "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Native Liquid Glass requires macOS 26." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネイティブLiquid GlassにはmacOS 26が必要です。" - } - } + "en": { "stringUnit": { "state": "translated", "value": "New Worktree Workspace…" } }, + "ja": { "stringUnit": { "state": "translated", "value": "新しいワークツリーワークスペース…" } } } }, - "debug.menu.title": { + "contextMenu.openAgentOverview": { "extractionState": "manual", "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Debug" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デバッグ" - } - } + "en": { "stringUnit": { "state": "translated", "value": "Open Agent Overview" } }, + "ja": { "stringUnit": { "state": "translated", "value": "エージェント概要を開く" } } } }, - "debug.menu.windowControls": { + "contextMenu.newWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Debug Window Controls…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デバッグウインドウコントロール…" + "value": "New Window" } } } }, - "debug.menu.windows": { + "contextMenu.pinWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Debug Windows" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デバッグウインドウ" + "value": "Pin Workspace" } } } }, - "debug.shortcutHints.alwaysShow": { + "contextMenu.pinWorkspaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Always Show Shortcut Hints" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ショートカットヒントを常に表示" + "value": "Pin Workspaces" } } } }, - "debug.titlebarControls.style": { + "contextMenu.removeCustomWorkspaceName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Titlebar Controls Style" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "タイトルバーコントロールのスタイル" + "value": "Remove Custom Workspace Name" } } } }, - "debug.updatePill.automatic": { + "contextMenu.renameWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Automatic Update Pill" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "更新ピルを自動表示" + "value": "Rename Workspace…" } } } }, - "debug.updatePill.hide": { + "contextMenu.stopUsingAsWorktreeFolder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Stop Using as Worktree Folder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダとしての使用を停止" } } + } + }, + "contextMenu.unpinWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide Update Pill" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "更新ピルを非表示" + "value": "Unpin Workspace" } } } }, - "debug.updatePill.menu": { + "contextMenu.unpinWorkspaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Update Pill" + "value": "Unpin Workspaces" } - }, - "ja": { + } + } + }, + "contextMenu.useAsWorktreeFolder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Use as Worktree Folder" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークツリーフォルダとして使用" } } + } + }, + "contextMenu.workspaceColor": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "更新ピル" + "value": "Workspace Color" } } } }, - "debug.updatePill.show": { + "controlBackgroundColor": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Update Pill" + "value": "controlBackgroundColor" } }, "ja": { "stringUnit": { "state": "translated", - "value": "更新ピルを表示" + "value": "controlBackgroundColor" } } } }, - "debug.updatePill.showLoading": { + "debug.browserToolbarGlass.enable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Loading State" + "value": "Enable Native Glass Browser Toolbar" } }, "ja": { "stringUnit": { "state": "translated", - "value": "読み込み状態を表示" + "value": "ネイティブガラスのブラウザツールバーを有効にする" } } } }, - "debug.updatePill.showLongNightly": { + "debug.browserToolbarGlass.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Long Nightly Pill" + "value": "Browser Toolbar Glass" } }, "ja": { "stringUnit": { "state": "translated", - "value": "長いNightly更新ピルを表示" + "value": "ブラウザツールバーガラス" } } } }, - "debug.windows.browserProfilePopover.title": { + "debug.browserProfilePopover.group.padding": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Profile Popover Debug" + "value": "Padding" } } } }, - "dialog.closeOtherTabs.title": { + "debug.browserProfilePopover.group.preview": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close other tabs?" + "value": "Preview" } } } }, - "dialog.closePinnedWorkspace.message": { + "debug.browserProfilePopover.heading": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This workspace is pinned. Closing it will close the workspace and all of its panels." + "value": "Browser Profile Popover" } } } }, - "dialog.closePinnedWorkspace.title": { + "debug.browserProfilePopover.label.horizontal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close pinned workspace?" + "value": "Horizontal" } } } }, - "dialog.closeTab.cancel": { + "debug.browserProfilePopover.label.vertical": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cancel" + "value": "Vertical" } } } }, - "dialog.closeTab.close": { + "debug.browserProfilePopover.liveNote": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close" + "value": "Changes apply live to the browser profile popover." } } } }, - "dialog.closeTab.message": { + "debug.browserProfilePopover.note": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will close the current tab." + "value": "Tune the profile popover padding live while comparing it against the browser toolbar menu." } } } }, - "dialog.closeTab.messageNamed": { + "debug.browserProfilePopover.reset": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will close \"%@\"." + "value": "Reset" } } } }, - "dialog.closeTab.title": { + "debug.devBuildBanner.show": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close tab?" + "value": "Show Dev Build Banner" } } } }, - "dialog.closeWindow.message": { + "debug.devBuildBanner.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will close the current window and all of its workspaces." + "value": "THIS IS A DEV BUILD" } } } }, - "dialog.closeWindow.title": { + "debug.menu.background": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close window?" + "value": "Background Debug…" } - } - } - }, - "dialog.closeWorkspace.message": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "This will close the workspace and all of its panels." + "value": "背景デバッグ…" } } } }, - "dialog.closeWorkspace.title": { + "debug.menu.browserProfilePopoverDebug": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close workspace?" + "value": "Browser Profile Popover Debug…" } } } }, - "dialog.closeWorkspaces.message": { + "debug.menu.browserToolbarGlass": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will close %1$lld workspaces and all of their panels:\n%2$@" + "value": "Browser Toolbar Glass Debug…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザツールバーガラスデバッグ…" } } } }, - "dialog.closeWorkspaces.title": { + "debug.menu.browserToolbarButtonSpacing": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close workspaces?" + "value": "Browser Toolbar Button Spacing" } } } }, - "dialog.closeWorkspacesWindow.message": { + "debug.menu.menuBarExtra": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" + "value": "Menu Bar Extra Debug…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバー追加項目デバッグ…" } } } }, - "dialog.cmuxConfig.confirmCommand.cancel": { + "debug.menu.overlayGlass": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cancel" + "value": "Overlay Glass Debug…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーバーレイガラスデバッグ…" } } } }, - "dialog.cmuxConfig.confirmCommand.configChanged": { + "debug.menu.newLargeScrollbackTab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This folder's programa.json has changed since you trusted it." + "value": "New Tab With Large Scrollback" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このフォルダのprograma.jsonは、信頼してから変更されています。" + "value": "大きなスクロールバック付きの新規タブ" } } } }, - "dialog.cmuxConfig.confirmCommand.messageWithCommand": { + "debug.menu.newLoremTab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will run the following command:\n\n%@" + "value": "New Tab With Lorem Search Text" } }, "ja": { "stringUnit": { "state": "translated", - "value": "次のコマンドを実行します:\n\n%@" + "value": "Lorem検索テキスト付きの新規タブ" } } } }, - "dialog.cmuxConfig.confirmCommand.run": { + "debug.menu.openAllWindows": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Run" + "value": "Open All Debug Windows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのデバッグウインドウを開く" } } } }, - "dialog.cmuxConfig.confirmCommand.title": { + "debug.menu.openStressWorkspacesWithLoadedSurfaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Run Command" + "value": "Open Stress Workspaces and Load All Terminals" } } } }, - "dialog.cmuxConfig.confirmCommand.truncated": { + "debug.menu.openWorkspaceColors": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "… (truncated)" + "value": "Open Workspaces for All Workspace Colors" } }, "ja": { "stringUnit": { "state": "translated", - "value": "…(省略)" + "value": "すべてのワークスペース色を開く" } } } }, - "dialog.cmuxConfig.confirmCommand.trustDirectory": { + "debug.menu.settingsAboutTitlebar": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Always trust commands from this folder" + "value": "Settings/About Titlebar Debug…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このフォルダのコマンドを常に信頼する" + "value": "設定/情報タイトルバーデバッグ…" } } } }, - "dialog.cmuxConfig.confirmCommand.workspaceSummary": { + "debug.menu.sidebar": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open workspace \"%@\"" + "value": "Sidebar Debug…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ワークスペース「%@」を開く" + "value": "サイドバーデバッグ…" } } } }, - "dialog.cmuxConfig.confirmRecipe.insert": { + "debug.menu.splitButtonLayout": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Insert" + "value": "Split Button Layout Debug…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "挿入" + "value": "分割ボタンレイアウトデバッグ…" } } } }, - "dialog.cmuxConfig.confirmRecipe.messageWithPrompt": { + "debug.overlayGlass.enable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will insert the following prompt into the focused terminal (it will not be sent until you press Return):\n\n%@" + "value": "Enable Native Glass Overlays" } }, "ja": { "stringUnit": { "state": "translated", - "value": "フォーカスされたターミナルに次のプロンプトを挿入します(Returnキーを押すまで送信されません):\n\n%@" + "value": "ネイティブガラスのオーバーレイを有効にする" } } } }, - "dialog.cmuxConfig.confirmRecipe.title": { + "debug.overlayGlass.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Insert Prompt" + "value": "Overlay Glass" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プロンプトを挿入" + "value": "オーバーレイガラス" } } } }, - "dialog.cmuxConfig.confirmRestart.cancel": { + "debug.menu.tabBarGlass": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cancel" + "value": "Tab Bar Glass Debug…" } - } - } - }, - "dialog.cmuxConfig.confirmRestart.message": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "A workspace with this name already exists. Close it and create a new one?" + "value": "タブバーガラスデバッグ…" } } } }, - "dialog.cmuxConfig.confirmRestart.recreate": { + "debug.tabBarGlass.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Recreate" + "value": "Tab Bar Glass" } - } - } - }, - "dialog.cmuxConfig.confirmRestart.title": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Workspace Already Exists" + "value": "タブバーガラス" } } } }, - "dialog.cmuxConfig.parameter.continue": { + "debug.tabBarGlass.enable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Continue" + "value": "Enable Native Glass Tab Pills" } }, "ja": { "stringUnit": { "state": "translated", - "value": "続ける" + "value": "ネイティブガラスのタブピルを有効にする" } } } }, - "dialog.cmuxConfig.parameter.message": { + "debug.glass.changesApplyLive": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enter a value for \"%@\"." + "value": "Changes apply live." } }, "ja": { "stringUnit": { "state": "translated", - "value": "「%@」の値を入力してください。" + "value": "変更はすぐに反映されます。" } } } }, - "dialog.dontWarnCmdQ": { + "debug.glass.requiresMacOS26": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Don't warn again for Cmd+Q" + "value": "Native Liquid Glass requires macOS 26." } - } - } - }, - "dialog.enableNotifications.message": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Notifications are disabled for Programa. Enable them in System Settings to see alerts." + "value": "ネイティブLiquid GlassにはmacOS 26が必要です。" } } } }, - "dialog.enableNotifications.notNow": { + "debug.menu.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Not Now" + "value": "Debug" } - } - } - }, - "dialog.enableNotifications.openSettings": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Open Settings" + "value": "デバッグ" } } } }, - "dialog.enableNotifications.title": { + "debug.menu.windowControls": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable Notifications for Programa" + "value": "Debug Window Controls…" } - } - } - }, - "dialog.renameWorkspace.message": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Enter a custom name for this workspace." + "value": "デバッグウインドウコントロール…" } } } }, - "dialog.renameWorkspace.placeholder": { + "debug.menu.windows": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Workspace name" + "value": "Debug Windows" } - } - } - }, - "dialog.renameWorkspace.title": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Rename Workspace" + "value": "デバッグウインドウ" } } } }, - "dialog.singleInstanceNotResponding.forceClose": { + "debug.shortcutHints.alwaysShow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Force Close" + "value": "Always Show Shortcut Hints" } }, "ja": { "stringUnit": { "state": "translated", - "value": "強制終了" + "value": "ショートカットヒントを常に表示" } } } }, - "dialog.singleInstanceNotResponding.message": { + "debug.titlebarControls.style": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." + "value": "Titlebar Controls Style" } }, "ja": { "stringUnit": { "state": "translated", - "value": "既存のProgramaインスタンスが応答していません。強制終了すると、保存されていないターミナルまたはセッションの状態が失われる可能性があります。" + "value": "タイトルバーコントロールのスタイル" } } } }, - "dialog.singleInstanceNotResponding.title": { + "debug.updatePill.automatic": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Programa Isn’t Responding" + "value": "Automatic Update Pill" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Programaが応答していません" + "value": "更新ピルを自動表示" } } } }, - "error.clipboardFolderPath": { + "debug.updatePill.hide": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could not load any folder path from the clipboard." + "value": "Hide Update Pill" } - } - } - }, - "error.remoteDrop.invalidFileURL": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Dropped item is not a file URL." + "value": "更新ピルを非表示" } } } }, - "error.remoteDrop.unavailable": { + "debug.updatePill.menu": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Remote drop is unavailable." + "value": "Update Pill" } - } - } - }, - "error.remoteDrop.uploadFailed": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Failed to upload dropped file: %@" + "value": "更新ピル" } } } }, - "markdown.compare.after": { + "debug.updatePill.show": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "After" + "value": "Show Update Pill" } }, "ja": { "stringUnit": { "state": "translated", - "value": "変更後" + "value": "更新ピルを表示" } } } }, - "markdown.compare.before": { + "debug.updatePill.showLoading": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Before" + "value": "Show Loading State" } }, "ja": { "stringUnit": { "state": "translated", - "value": "変更前" + "value": "読み込み状態を表示" } } } }, - "markdown.fileUnavailable.message": { + "debug.updatePill.showLongNightly": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The file may have been moved or deleted." + "value": "Show Long Nightly Pill" } - } - } - }, - "markdown.fileUnavailable.title": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "File unavailable" + "value": "長いNightly更新ピルを表示" } } } }, - "menu.app.about": { + "debug.windows.browserProfilePopover.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "About Programa" + "value": "Browser Profile Popover Debug" } } } }, - "menu.app.checkForUpdates": { + "dialog.closeOtherTabs.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check for Updates…" + "value": "Close other tabs?" } } } }, - "menu.app.ghosttySettings": { + "dialog.closePinnedWorkspace.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ghostty Settings…" + "value": "This workspace is pinned. Closing it will close the workspace and all of its panels." } } } }, - "menu.app.reloadConfiguration": { + "dialog.closePinnedWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reload Configuration" + "value": "Close pinned workspace?" } } } }, - "menu.app.settings": { + "dialog.closeTab.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Settings…" + "value": "Cancel" } } } }, - "menu.checkForUpdates": { + "dialog.closeTab.close": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check for Updates…" + "value": "Close" } } } }, - "menu.currentWindow": { + "dialog.closeTab.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Current Window" + "value": "This will close the current tab." } } } }, - "menu.file.closeOtherTabs": { + "dialog.closeTab.messageNamed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Other Tabs in Pane" + "value": "This will close \"%@\"." } } } }, - "menu.file.closeTab": { + "dialog.closeTab.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Tab" + "value": "Close tab?" } } } }, - "menu.file.closeWorkspace": { + "dialog.closeWindow.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Workspace" + "value": "This will close the current window and all of its workspaces." } } } }, - "menu.file.commandPalette": { + "dialog.closeWindow.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command Palette…" + "value": "Close window?" } } } }, - "menu.file.goToWorkspace": { + "dialog.closeWorkspace.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Go to Workspace…" + "value": "This will close the workspace and all of its panels." } } } }, - "menu.file.installClaudeIntegration": { + "dialog.closeWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install Claude Code Integration…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Claude Code 連携をインストール…" + "value": "Close workspace?" } } } }, - "menu.file.installCodexIntegration": { + "dialog.closeWorkspaces.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install Codex Integration…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Codex 連携をインストール…" + "value": "This will close %1$lld workspaces and all of their panels:\n%2$@" } } } }, - "menu.file.installOpenCodeIntegration": { + "dialog.closeWorkspaces.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install OpenCode Integration…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "OpenCode 連携をインストール…" + "value": "Close workspaces?" } } } }, - "menu.file.newClaudeWorkspace": { + "dialog.closeWorkspacesWindow.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Claude Code Workspace" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新規 Claude Code ワークスペース" + "value": "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" } } } }, - "menu.file.newWindow": { + "dialog.cmuxConfig.confirmCommand.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Window" + "value": "Cancel" } } } }, - "menu.file.newWorkspace": { + "dialog.cmuxConfig.confirmCommand.configChanged": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Workspace" + "value": "This folder's programa.json has changed since you trusted it." } - } - } - }, - "menu.file.openFolder": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Open Folder…" + "value": "このフォルダのprograma.jsonは、信頼してから変更されています。" } } } }, - "menu.file.openFolder.panelPrompt": { + "dialog.cmuxConfig.confirmCommand.messageWithCommand": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open" + "value": "This will run the following command:\n\n%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次のコマンドを実行します:\n\n%@" } } } }, - "menu.file.openFolder.panelTitle": { + "dialog.cmuxConfig.confirmCommand.run": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Folder" + "value": "Run" } } } }, - "menu.file.openFolderInVSCodeInline": { + "dialog.cmuxConfig.confirmCommand.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Folder in VS Code (Inline)…" + "value": "Run Command" } } } }, - "menu.file.openFolderInVSCodeInline.panelPrompt": { + "dialog.cmuxConfig.confirmCommand.truncated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open in VS Code" + "value": "… (truncated)" } - } - } - }, - "menu.file.openFolderInVSCodeInline.panelTitle": { - "extractionState": "manual", - "localizations": { - "en": { + }, + "ja": { "stringUnit": { "state": "translated", - "value": "Open Folder in VS Code (Inline)" + "value": "…(省略)" } } } }, - "menu.file.reopenClosedBrowserPanel": { + "dialog.cmuxConfig.confirmCommand.trustDirectory": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reopen Closed Panel" + "value": "Always trust commands from this folder" } }, "ja": { "stringUnit": { "state": "translated", - "value": "閉じたパネルを再度開く" + "value": "このフォルダのコマンドを常に信頼する" } } } }, - "menu.find.find": { + "dialog.cmuxConfig.confirmCommand.workspaceSummary": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find…" + "value": "Open workspace \"%@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペース「%@」を開く" } } } }, - "menu.find.findNext": { + "dialog.cmuxConfig.confirmRecipe.insert": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find Next" + "value": "Insert" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "挿入" } } } }, - "menu.find.findPrevious": { + "dialog.cmuxConfig.confirmRecipe.messageWithPrompt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find Previous" + "value": "This will insert the following prompt into the focused terminal (it will not be sent until you press Return):\n\n%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカスされたターミナルに次のプロンプトを挿入します(Returnキーを押すまで送信されません):\n\n%@" } } } }, - "menu.find.hideFindBar": { + "dialog.cmuxConfig.confirmRecipe.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide Find Bar" + "value": "Insert Prompt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロンプトを挿入" } } } }, - "menu.find.title": { + "dialog.cmuxConfig.confirmRestart.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Find" + "value": "Cancel" } } } }, - "menu.find.useSelectionForFind": { + "dialog.cmuxConfig.confirmRestart.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Use Selection for Find" + "value": "A workspace with this name already exists. Close it and create a new one?" } } } }, - "menu.notifications.clearAll": { + "dialog.cmuxConfig.confirmRestart.recreate": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear All" + "value": "Recreate" } } } }, - "menu.notifications.jumpToUnread": { + "dialog.cmuxConfig.confirmRestart.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jump to Latest Unread" + "value": "Workspace Already Exists" } } } }, - "menu.notifications.markAllRead": { + "dialog.cmuxConfig.parameter.continue": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mark All Read" + "value": "Continue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "続ける" } } } }, - "menu.notifications.show": { + "dialog.cmuxConfig.parameter.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Notifications" + "value": "Enter a value for \"%@\"." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」の値を入力してください。" } } } }, - "menu.notifications.title": { + "dialog.dontWarnCmdQ": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notifications" + "value": "Don't warn again for Cmd+Q" } } } }, - "menu.openInAndroidStudio": { + "dialog.enableNotifications.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Android Studio" + "value": "Notifications are disabled for Programa. Enable them in System Settings to see alerts." } } } }, - "menu.openInAntigravity": { + "dialog.enableNotifications.notNow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Antigravity" + "value": "Not Now" } } } }, - "menu.openInCursor": { + "dialog.enableNotifications.openSettings": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Cursor" + "value": "Open Settings" } } } }, - "menu.openInFinder": { + "dialog.enableNotifications.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Finder" + "value": "Enable Notifications for Programa" } } } }, - "menu.openInGhostty": { + "dialog.renameWorkspace.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Ghostty" + "value": "Enter a custom name for this workspace." } } } }, - "menu.openInITerm2": { + "dialog.renameWorkspace.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in iTerm2" + "value": "Workspace name" } } } }, - "menu.openInIntelliJ": { + "dialog.renameWorkspace.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in IntelliJ IDEA" + "value": "Rename Workspace" } } } }, - "menu.openInTerminal": { + "dialog.singleInstanceNotResponding.forceClose": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Terminal" + "value": "Force Close" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "強制終了" } } } }, - "menu.openInTower": { + "dialog.singleInstanceNotResponding.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Tower" + "value": "The existing Programa instance is not responding. Force closing it may lose unsaved terminal or session state." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既存のProgramaインスタンスが応答していません。強制終了すると、保存されていないターミナルまたはセッションの状態が失われる可能性があります。" } } } }, - "menu.openInVSCode": { + "dialog.singleInstanceNotResponding.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in VS Code (Inline)" + "value": "Programa Isn’t Responding" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Programaが応答していません" } } } }, - "menu.openInVSCodeDesktop": { + "error.clipboardFolderPath": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in VS Code" + "value": "Could not load any folder path from the clipboard." } } } }, - "menu.openInWarp": { + "markdown.compare.after": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Warp" + "value": "After" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更後" } } } }, - "menu.openInWindsurf": { + "markdown.compare.before": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Windsurf" + "value": "Before" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更前" } } } }, - "menu.openInXcode": { + "markdown.fileUnavailable.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Xcode" + "value": "The file may have been moved or deleted." } } } }, - "menu.openInZed": { + "markdown.fileUnavailable.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Current Directory in Zed" + "value": "File unavailable" } } } }, - "menu.preferences": { + "menu.app.about": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Preferences…" + "value": "About Programa" } } } }, - "menu.updateLogs.copyFocusLogs": { + "menu.app.checkForUpdates": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy Focus Logs" + "value": "Check for Updates…" } } } }, - "menu.updateLogs.copyUpdateLogs": { + "menu.app.ghosttySettings": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy Update Logs" + "value": "Ghostty Settings…" } } } }, - "menu.view.actualSize": { + "menu.app.reloadConfiguration": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Actual Size" + "value": "Reload Configuration" } } } }, - "menu.view.back": { + "menu.app.settings": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Back" + "value": "Settings…" } } } }, - "menu.view.clearBrowserHistory": { + "menu.checkForUpdates": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear Browser History" + "value": "Check for Updates…" } } } }, - "menu.view.editWorkspaceDescription": { + "menu.currentWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Edit Workspace Description…" + "value": "Current Window" } } } }, - "menu.view.forward": { + "menu.file.closeOtherTabs": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Forward" + "value": "Close Other Tabs in Pane" } } } }, - "menu.view.importFromBrowser": { + "menu.file.closeTab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import Browser Data…" + "value": "Close Tab" } } } }, - "menu.view.jumpToUnread": { + "menu.file.closeWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jump to Latest Unread" + "value": "Close Workspace" } } } }, - "menu.view.nextSurface": { + "menu.file.commandPalette": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next Surface" + "value": "Command Palette…" } } } }, - "menu.view.nextWorkspace": { + "menu.file.goToWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next Workspace" + "value": "Go to Workspace…" } } } }, - "menu.view.previousSurface": { + "menu.file.installClaudeIntegration": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Previous Surface" + "value": "Install Claude Code Integration…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code 連携をインストール…" } } } }, - "menu.view.previousWorkspace": { + "menu.file.installCodexIntegration": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Previous Workspace" + "value": "Install Codex Integration…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex 連携をインストール…" } } } }, - "menu.view.reloadPage": { + "menu.file.installOpenCodeIntegration": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reload Page" + "value": "Install OpenCode Integration…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OpenCode 連携をインストール…" } } } }, - "menu.view.renameWorkspace": { + "menu.file.newClaudeWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Rename Workspace…" + "value": "New Claude Code Workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規 Claude Code ワークスペース" } } } }, - "menu.view.showJSConsole": { + "menu.file.newWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show JavaScript Console" + "value": "New Window" } } } }, - "menu.view.showNotifications": { + "menu.file.newWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Notifications" + "value": "New Workspace" } } } }, - "menu.view.splitBrowserDown": { + "menu.file.openFolder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Browser Down" + "value": "Open Folder…" } } } }, - "menu.view.splitBrowserRight": { + "menu.file.openFolder.panelPrompt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Browser Right" + "value": "Open" } } } }, - "menu.view.splitDown": { + "menu.file.openFolder.panelTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Down" + "value": "Open Folder" } } } }, - "menu.view.splitRight": { + "menu.file.reopenClosedBrowserPanel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Split Right" + "value": "Reopen Closed Panel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じたパネルを再度開く" } } } }, - "menu.view.toggleDevTools": { + "menu.find.find": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Toggle Developer Tools" + "value": "Find…" } } } }, - "menu.view.toggleReactGrab": { + "menu.find.findNext": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Toggle React Grab" + "value": "Find Next" } } } }, - "menu.view.toggleSidebar": { + "menu.find.findPrevious": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Toggle Sidebar" + "value": "Find Previous" } } } }, - "menu.view.workspace": { + "menu.find.hideFindBar": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Workspace %lld" + "value": "Hide Find Bar" } } } }, - "menu.view.zoomIn": { + "menu.find.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom In" + "value": "Find" } } } }, - "menu.view.zoomOut": { + "menu.find.useSelectionForFind": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom Out" + "value": "Use Selection for Find" } } } }, - "menu.windowNumber": { + "menu.notifications.clearAll": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Window %lld" + "value": "Clear All" } } } }, - "notification.longCommand.failed": { + "menu.notifications.jumpToUnread": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed (exit %@) after %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "失敗しました (終了コード %@) - %@ 経過" + "value": "Jump to Latest Unread" } } } }, - "notification.longCommand.succeeded": { + "menu.notifications.markAllRead": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Finished in %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ で完了しました" + "value": "Mark All Read" } } } }, - "notifications.clearAll": { + "menu.notifications.show": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear All" + "value": "Show Notifications" } } } }, - "notifications.empty.description": { + "menu.notifications.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Desktop notifications will appear here for quick review." + "value": "Notifications" } } } }, - "notifications.empty.subtitle": { + "menu.openInAndroidStudio": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Desktop notifications will appear here." + "value": "Open Current Directory in Android Studio" } } } }, - "notifications.empty.title": { + "menu.openInAntigravity": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No notifications yet" + "value": "Open Current Directory in Antigravity" } } } }, - "notifications.jumpToLatest": { + "menu.openInCursor": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jump to Latest" + "value": "Open Current Directory in Cursor" } } } }, - "notifications.jumpToLatestUnread": { + "menu.openInFinder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jump to Latest Unread" + "value": "Open Current Directory in Finder" } } } }, - "notifications.title": { + "menu.openInGhostty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notifications" + "value": "Open Current Directory in Ghostty" } } } }, - "notificationsPopover.markRead": { + "menu.openInITerm2": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mark as Read" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "既読にする" + "value": "Open Current Directory in iTerm2" } } } }, - "notificationsPopover.markUnread": { + "menu.openInIntelliJ": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mark as Unread" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未読にする" + "value": "Open Current Directory in IntelliJ IDEA" } } } }, - "panel.displayName.fallback": { + "menu.openInTerminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "Open Current Directory in Terminal" } } } }, - "panel.openFolder.prompt": { + "menu.openInTower": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open" + "value": "Open Current Directory in Tower" } } } }, - "panel.openFolder.title": { + "menu.openInVSCodeDesktop": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Folder" + "value": "Open Current Directory in VS Code" } } } }, - "remote.status.connected": { + "menu.openInWarp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connected" + "value": "Open Current Directory in Warp" } } } }, - "remote.status.connecting": { + "menu.openInWindsurf": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connecting" + "value": "Open Current Directory in Windsurf" } } } }, - "remote.status.disconnected": { + "menu.openInXcode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Disconnected" + "value": "Open Current Directory in Xcode" } } } }, - "remote.status.error": { + "menu.openInZed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Error" + "value": "Open Current Directory in Zed" } } } }, - "remoteDaemon.error.goRequired": { + "menu.preferences": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Go is required for the development-only programad-remote build fallback." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "開発専用のprogramad-remoteビルドフォールバックにはGoが必要です。" + "value": "Preferences…" } } } }, - "remoteDaemon.error.missingDaemonModule": { + "menu.updateLogs.copyFocusLogs": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Missing daemon module at %@." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@にデーモンモジュールがありません。" + "value": "Copy Focus Logs" } } } }, - "remoteDaemon.error.missingVerifiedManifest": { + "menu.updateLogs.copyUpdateLogs": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This build does not include a verified programad-remote manifest for %@-%@. Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このビルドには%@-%@用の検証済みprogramad-remoteマニフェストが含まれていません。リリースビルドを使用するか、開発専用のフォールバックとしてPROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1を設定してください。" + "value": "Copy Update Logs" } } } }, - "remoteDaemon.error.repoRootNotFound": { + "menu.view.actualSize": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cannot locate the Programa repository root for the development-only programad-remote build fallback." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "開発専用のprogramad-remoteビルドフォールバック用のProgramaリポジトリルートが見つかりません。" + "value": "Actual Size" } } } }, - "search.close.help": { + "menu.view.back": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close (Esc)" + "value": "Back" } } } }, - "search.nextMatch.help": { + "menu.view.clearBrowserHistory": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next match (Return)" + "value": "Clear Browser History" } } } }, - "search.placeholder": { + "menu.view.editWorkspaceDescription": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Search" + "value": "Edit Workspace Description…" } } } }, - "search.previousMatch.help": { + "menu.view.forward": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Previous match (Shift+Return)" + "value": "Forward" } } } }, - "settings.app.commandPaletteSearchAllSurfaces": { + "menu.view.jumpToUnread": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command Palette Searches All Surfaces" + "value": "Jump to Latest Unread" } } } }, - "settings.app.commandPaletteSearchAllSurfaces.subtitleOff": { + "menu.view.nextSurface": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cmd+P matches workspace rows only." + "value": "Next Surface" } } } }, - "settings.app.commandPaletteSearchAllSurfaces.subtitleOn": { + "menu.view.nextWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces." + "value": "Next Workspace" } } } }, - "settings.app.minimalMode": { + "menu.view.previousSurface": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Minimal Mode" + "value": "Previous Surface" } } } }, - "settings.app.minimalMode.subtitleOff": { + "menu.view.previousWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Use the standard workspace title bar and controls." + "value": "Previous Workspace" } } } }, - "settings.app.minimalMode.subtitleOn": { + "menu.view.reloadPage": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hide the workspace title bar and move workspace controls into the sidebar." + "value": "Reload Page" } } } }, - "settings.app.newWorkspacePlacement": { + "menu.view.renameWorkspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "New Workspace Placement" + "value": "Rename Workspace…" } } } }, - "settings.app.persistScrollback": { + "menu.view.showJSConsole": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Save Scrollback on Quit" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "終了時にスクロールバックを保存" + "value": "Show JavaScript Console" } } } }, - "settings.app.persistScrollback.subtitleOff": { + "menu.view.showNotifications": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal scrollback is not written to disk." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "スクロールバックはディスクに書き込まれません。" + "value": "Show Notifications" } } } }, - "settings.app.persistScrollback.subtitleOn": { + "menu.view.splitBrowserDown": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal scrollback is saved and restored on next launch." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "スクロールバックは保存され、次回起動時に復元されます。" + "value": "Split Browser Down" } } } }, - "settings.app.reorderOnNotification": { + "menu.view.splitBrowserRight": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reorder on Notification" + "value": "Split Browser Right" } } } }, - "settings.app.reorderOnNotification.subtitle": { + "menu.view.splitDown": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions." + "value": "Split Down" } } } }, - "settings.app.settingsFile.openButton": { + "menu.view.splitRight": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open settings.json" + "value": "Split Right" } } } }, - "settings.app.showInMenuBar": { + "menu.view.toggleDevTools": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show in Menu Bar" + "value": "Toggle Developer Tools" } } } }, - "settings.app.showInMenuBar.subtitle": { + "menu.view.toggleSidebar": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Keep Programa in the menu bar for unread notifications and quick actions." + "value": "Toggle Sidebar" } } } }, - "settings.app.theme": { + "menu.view.workspace": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Theme" + "value": "Workspace %lld" } } } }, - "settings.app.warnBeforeQuit": { + "menu.view.zoomIn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Warn Before Quit" + "value": "Zoom In" } } } }, - "settings.app.warnBeforeQuit.subtitleOff": { + "menu.view.zoomOut": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cmd+Q quits immediately without confirmation." + "value": "Zoom Out" } } } }, - "settings.app.warnBeforeQuit.subtitleOn": { + "menu.windowNumber": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show a confirmation before quitting with Cmd+Q." + "value": "Window %lld" } } } }, - "settings.automation.agentScreenDetection": { + "notification.longCommand.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Screen-Based Agent Detection" + "value": "Failed (exit %@) after %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "画面ベースのエージェント検出" + "value": "失敗しました (終了コード %@) - %@ 経過" } } } }, - "settings.automation.agentScreenDetection.note": { + "notification.longCommand.succeeded": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Applies to agents such as Gemini CLI and GitHub Copilot CLI that have no installed hooks. A hooks-managed session (Claude Code, Codex, OpenCode) always takes priority over this." + "value": "Finished in %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "フックが未導入のエージェント(Gemini CLI、GitHub Copilot CLI など)に適用されます。フックで管理されているセッション(Claude Code、Codex、OpenCode)は常にこちらより優先されます。" + "value": "%@ で完了しました" } } } }, - "settings.automation.agentScreenDetection.subtitleOff": { + "notifications.clearAll": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Agent status is only shown for CLIs with an installed integration." + "value": "Clear All" } - }, - "ja": { + } + } + }, + "notifications.empty.description": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "エージェントの状態は、連携機能が導入済みの CLI でのみ表示されます。" + "value": "Desktop notifications will appear here for quick review." } } } }, - "settings.automation.agentScreenDetection.subtitleOn": { + "notifications.empty.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Infers working/blocked/idle status for agent CLIs without an installed integration by reading the terminal screen." + "value": "Desktop notifications will appear here." } - }, - "ja": { + } + } + }, + "notifications.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "連携機能が未導入のエージェント CLI について、ターミナル画面を読み取って作業中/ブロック中/待機中の状態を推測します。" + "value": "No notifications yet" } } } }, - "settings.automation.claudeCode": { + "notifications.jumpToLatest": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Claude Code Integration" + "value": "Jump to Latest" } } } }, - "settings.automation.claudeCode.customPath": { + "notifications.jumpToLatestUnread": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Claude Binary Path" + "value": "Jump to Latest Unread" } } } }, - "settings.automation.claudeCode.customPath.placeholder": { + "notifications.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "e.g. /usr/local/bin/claude" + "value": "Notifications" } } } }, - "settings.automation.claudeCode.customPath.subtitle": { + "notificationsPopover.markRead": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Custom path to the claude binary. Leave empty to use PATH." + "value": "Mark as Read" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既読にする" } } } }, - "settings.automation.claudeCode.note": { + "notificationsPopover.markUnread": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "When enabled, Programa wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself." + "value": "Mark as Unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読にする" } } } }, - "settings.automation.claudeCode.subtitleOff": { + "panel.displayName.fallback": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Claude Code runs without Programa integration." + "value": "Tab" } } } }, - "settings.automation.claudeCode.subtitleOn": { + "panel.openFolder.prompt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sidebar shows Claude session status and notifications." + "value": "Open" } } } }, - "settings.automation.openAccess.dialog.cancel": { + "panel.openFolder.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cancel" + "value": "Open Folder" } } } }, - "settings.automation.openAccess.dialog.confirm": { + "search.close.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable Full Open Access" + "value": "Close (Esc)" } } } }, - "settings.automation.openAccess.dialog.message": { + "search.nextMatch.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk." + "value": "Next match (Return)" } } } }, - "settings.automation.openAccess.dialog.title": { + "search.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enable full open access?" + "value": "Search" } } } }, - "settings.automation.openAccessWarning": { + "search.previousMatch.help": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging." + "value": "Previous match (Shift+Return)" } } } }, - "settings.automation.port.note": { + "settings.agents.browserSplit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Each workspace gets PROGRAMA_PORT and PROGRAMA_PORT_END env vars with a dedicated port range. New terminals inherit these values." + "value": "Open a browser beside new agents" } } } }, - "settings.automation.portBase": { + "settings.agents.browserSplit.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Port Base" + "value": "New agent workspaces open with a terminal only." } } } }, - "settings.automation.portBase.subtitle": { + "settings.agents.browserSplit.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Starting port for PROGRAMA_PORT env var." + "value": "New agent workspaces get a browser split next to the terminal." } } } }, - "settings.automation.portRange": { + "settings.app.commandPaletteSearchAllSurfaces": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Port Range Size" + "value": "Command Palette Searches All Surfaces" } } } }, - "settings.automation.portRange.subtitle": { + "settings.app.commandPaletteSearchAllSurfaces.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Number of ports per workspace." + "value": "Cmd+P matches workspace rows only." } } } }, - "settings.automation.socketMode": { + "settings.app.commandPaletteSearchAllSurfaces.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Socket Control Mode" + "value": "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces." } } } }, - "settings.automation.socketMode.note": { + "settings.app.minimalMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model." + "value": "Minimal Mode" } } } }, - "settings.automation.socketOverrides.note": { + "settings.app.minimalMode.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Overrides: PROGRAMA_SOCKET_ENABLE, PROGRAMA_SOCKET_MODE, and PROGRAMA_SOCKET_PATH (set PROGRAMA_ALLOW_SOCKET_OVERRIDE=1 for release builds)." + "value": "Use the standard workspace title bar and controls." } } } }, - "settings.automation.socketPassword": { + "settings.app.minimalMode.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Socket Password" + "value": "Hide the workspace title bar and move workspace controls into the sidebar." } } } }, - "settings.automation.socketPassword.change": { + "settings.app.newWorkspacePlacement": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Change" + "value": "New Workspace Placement" } } } }, - "settings.automation.socketPassword.clear": { + "settings.app.persistScrollback": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear" + "value": "Save Scrollback on Quit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了時にスクロールバックを保存" } } } }, - "settings.automation.socketPassword.clearFailed": { + "settings.app.persistScrollback.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to clear password (%@)." + "value": "Terminal scrollback is not written to disk." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スクロールバックはディスクに書き込まれません。" } } } }, - "settings.automation.socketPassword.cleared": { + "settings.app.persistScrollback.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Password cleared." + "value": "Terminal scrollback is saved and restored on next launch." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スクロールバックは保存され、次回起動時に復元されます。" } } } }, - "settings.automation.socketPassword.enterFirst": { + "settings.app.reorderOnNotification": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Enter a password first." + "value": "Reorder on Notification" } } } }, - "settings.automation.socketPassword.placeholder": { + "settings.app.reorderOnNotification.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Password" + "value": "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions." } } } }, - "settings.automation.socketPassword.saveFailed": { + "settings.app.settingsFile.openButton": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed to save password (%@)." + "value": "Open settings.json" } } } }, - "settings.automation.socketPassword.saved": { + "settings.app.showInMenuBar": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Password saved." + "value": "Show in Menu Bar" } } } }, - "settings.automation.socketPassword.set": { + "settings.app.showInMenuBar.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set" + "value": "Keep Programa in the menu bar for unread notifications and quick actions." } } } }, - "settings.automation.socketPassword.subtitleSet": { + "settings.app.theme": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Stored in Application Support." + "value": "Theme" } } } }, - "settings.automation.socketPassword.subtitleUnset": { + "settings.app.warnBeforeQuit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No password set. External clients will be blocked until one is configured." + "value": "Warn Before Quit" } } } }, - "settings.blendMode.behindWindow": { + "settings.app.warnBeforeQuit.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Behind Window" + "value": "Cmd+Q quits immediately without confirmation." } } } }, - "settings.blendMode.withinWindow": { + "settings.app.warnBeforeQuit.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Within Window" + "value": "Show a confirmation before quitting with Cmd+Q." } } } }, - "settings.browser.externalPatterns": { + "settings.automation.agentScreenDetection": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "URLs to Always Open Externally" + "value": "Screen-Based Agent Detection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面ベースのエージェント検出" } } } }, - "settings.browser.externalPatterns.subtitle": { + "settings.automation.agentScreenDetection.note": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." + "value": "Applies to agents such as Gemini CLI and GitHub Copilot CLI that have no installed hooks. A hooks-managed session (Claude Code, Codex, OpenCode) always takes priority over this." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックが未導入のエージェント(Gemini CLI、GitHub Copilot CLI など)に適用されます。フックで管理されているセッション(Claude Code、Codex、OpenCode)は常にこちらより優先されます。" } } } }, - "settings.browser.history": { + "settings.automation.agentScreenDetection.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browsing History" + "value": "Agent status is only shown for CLIs with an installed integration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントの状態は、連携機能が導入済みの CLI でのみ表示されます。" } } } }, - "settings.browser.history.clearButton": { + "settings.automation.agentScreenDetection.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear History…" + "value": "Infers working/blocked/idle status for agent CLIs without an installed integration by reading the terminal screen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "連携機能が未導入のエージェント CLI について、ターミナル画面を読み取って作業中/ブロック中/待機中の状態を推測します。" } } } }, - "settings.browser.history.clearDialog.cancel": { + "settings.automation.claudeCode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Cancel" + "value": "Claude Code Integration" } } } }, - "settings.browser.history.clearDialog.confirm": { + "settings.automation.claudeCode.customPath": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear History" + "value": "Claude Binary Path" } } } }, - "settings.browser.history.clearDialog.message": { + "settings.automation.claudeCode.customPath.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This removes visited-page suggestions from the browser omnibar." + "value": "e.g. /usr/local/bin/claude" } } } }, - "settings.browser.history.clearDialog.title": { + "settings.automation.claudeCode.customPath.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear browser history?" + "value": "Custom path to the claude binary. Leave empty to use PATH." } } } }, - "settings.browser.history.subtitleEmpty": { + "settings.automation.claudeCode.note": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No saved pages yet." + "value": "When enabled, Programa wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself." } } } }, - "settings.browser.history.subtitleMany": { + "settings.automation.claudeCode.subtitleOff": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%lld saved pages appear in omnibar suggestions." + "value": "Claude Code runs without Programa integration." } } } }, - "settings.browser.history.subtitleOne": { + "settings.automation.claudeCode.subtitleOn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "1 saved page appears in omnibar suggestions." + "value": "Sidebar shows Claude session status and notifications." } } } }, - "settings.browser.hostWhitelist": { + "settings.automation.openAccess.dialog.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Hosts to Open in Embedded Browser" + "value": "Cancel" } } } }, - "settings.browser.hostWhitelist.subtitle": { + "settings.automation.openAccess.dialog.confirm": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in Programa. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in Programa." + "value": "Enable Full Open Access" } } } }, - "settings.browser.httpAllowlist": { + "settings.automation.openAccess.dialog.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "HTTP Hosts Allowed in Embedded Browser" + "value": "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk." } } } }, - "settings.browser.httpAllowlist.description": { + "settings.automation.openAccess.dialog.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Controls which HTTP (non-HTTPS) hosts can open in Programa without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me." + "value": "Enable full open access?" } } } }, - "settings.browser.httpAllowlist.hint": { + "settings.automation.openAccessWarning": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." + "value": "Warning: Full open access makes the control socket world-readable/writable on this Mac and disables auth checks. Use only for local debugging." } } } }, - "settings.browser.httpAllowlist.save": { + "settings.automation.port.note": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Save" + "value": "Each workspace gets PROGRAMA_PORT and PROGRAMA_PORT_END env vars with a dedicated port range. New terminals inherit these values." } } } }, - "settings.browser.import": { + "settings.automation.portBase": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Import Browser Data" + "value": "Port Base" } } } }, - "settings.browser.import.choose": { + "settings.automation.portBase.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose…" + "value": "Starting port for PROGRAMA_PORT env var." } } } }, - "settings.browser.import.hint.note.hidden": { + "settings.automation.portRange": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The blank-tab import hint is hidden. Turn it back on here any time." + "value": "Port Range Size" } } } }, - "settings.browser.import.hint.note.visible": { + "settings.automation.portRange.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Blank browser tabs can show this import suggestion. Hide or re-enable it here." + "value": "Number of ports per workspace." } } } }, - "settings.browser.import.hint.show": { + "settings.automation.socketMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show import hint on blank browser tabs" + "value": "Socket Control Mode" } } } }, - "settings.browser.import.refresh": { + "settings.automation.socketMode.note": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Refresh" + "value": "Controls access to the local Unix socket for programmatic control. Choose a mode that matches your threat model." } } } }, - "settings.browser.interceptOpen": { + "settings.automation.socketOverrides.note": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Intercept open http(s) in Terminal" + "value": "Overrides: PROGRAMA_SOCKET_ENABLE, PROGRAMA_SOCKET_MODE, and PROGRAMA_SOCKET_PATH (set PROGRAMA_ALLOW_SOCKET_OVERRIDE=1 for release builds)." } } } }, - "settings.browser.interceptOpen.subtitle": { + "settings.automation.socketPassword": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "When off, `open https://...` and `open http://...` always use your default browser." + "value": "Socket Password" } } } }, - "settings.browser.openTerminalLinks": { + "settings.automation.socketPassword.change": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open Terminal Links in Programa Browser" + "value": "Change" } } } }, - "settings.browser.openTerminalLinks.subtitle": { + "settings.automation.socketPassword.clear": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "When off, links clicked in terminal output open in your default browser." + "value": "Clear" } } } }, - "settings.browser.searchEngine": { + "settings.automation.socketPassword.clearFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Default Search Engine" + "value": "Failed to clear password (%@)." } } } }, - "settings.browser.searchEngine.subtitle": { + "settings.automation.socketPassword.cleared": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Used by the browser address bar when input is not a URL." + "value": "Password cleared." } } } }, - "settings.browser.searchSuggestions": { + "settings.automation.socketPassword.enterFirst": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Search Suggestions" + "value": "Enter a password first." } } } }, - "settings.browser.theme": { + "settings.automation.socketPassword.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browser Theme" + "value": "Password" } } } }, - "settings.browser.theme.subtitleForced": { + "settings.automation.socketPassword.saveFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ forces that color scheme for compatible pages." + "value": "Failed to save password (%@)." } } } }, - "settings.browser.theme.subtitleSystem": { + "settings.automation.socketPassword.saved": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "System follows app and macOS appearance." + "value": "Password saved." } } } }, - "settings.material.contentBackground": { + "settings.automation.socketPassword.set": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Content Background" + "value": "Set" } } } }, - "settings.material.fullScreenUI": { + "settings.automation.socketPassword.subtitleSet": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Full Screen UI" + "value": "Stored in Application Support." } } } }, - "settings.material.headerView": { + "settings.automation.socketPassword.subtitleUnset": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Header View" + "value": "No password set. External clients will be blocked until one is configured." } } } }, - "settings.material.hudWindow": { + "settings.blendMode.behindWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "HUD Window" + "value": "Behind Window" } } } }, - "settings.material.liquidGlass": { + "settings.blendMode.withinWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Liquid Glass (macOS 26+)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リキッドガラス(macOS 26以降)" + "value": "Within Window" } } } }, - "settings.material.menu": { + "settings.browser.externalPatterns": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Menu" + "value": "URLs to Always Open Externally" } } } }, - "settings.material.none": { + "settings.browser.externalPatterns.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "None" + "value": "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\\\.example\\\\.com/(billing|usage))." } } } }, - "settings.material.popover": { + "settings.browser.history": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Popover" + "value": "Browsing History" } } } }, - "settings.material.sheet": { + "settings.browser.history.clearButton": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sheet" + "value": "Clear History…" } } } }, - "settings.material.sidebar": { + "settings.browser.history.clearDialog.cancel": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sidebar" + "value": "Cancel" } } } }, - "settings.material.toolTip": { + "settings.browser.history.clearDialog.confirm": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tool Tip" + "value": "Clear History" } } } }, - "settings.material.underWindow": { + "settings.browser.history.clearDialog.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Under Window" + "value": "This removes visited-page suggestions from the browser omnibar." } } } }, - "settings.material.windowBackground": { + "settings.browser.history.clearDialog.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Window Background" + "value": "Clear browser history?" } } } }, - "settings.notifications.command.placeholder": { + "settings.browser.history.subtitleEmpty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "say \"done\"" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "say \"done\"" + "value": "No saved pages yet." } } } }, - "settings.notifications.command.subtitle": { + "settings.browser.history.subtitleMany": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "通知が届いたときにシェルコマンドを実行します。$PROGRAMA_NOTIFICATION_TITLE、$PROGRAMA_NOTIFICATION_SUBTITLE、$PROGRAMA_NOTIFICATION_BODY が設定されます。" + "value": "%lld saved pages appear in omnibar suggestions." } } } }, - "settings.notifications.command.title": { + "settings.browser.history.subtitleOne": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notification Command" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "通知コマンド" + "value": "1 saved page appears in omnibar suggestions." } } } }, - "settings.notifications.desktop.title": { + "settings.browser.hostWhitelist": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Desktop Notifications" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デスクトップ通知" + "value": "Hosts to Open in Embedded Browser" } } } }, - "settings.notifications.longCommandThreshold.subtitle": { + "settings.browser.hostWhitelist.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notify when a command finishes in a pane you're not looking at, if it ran at least this long. Set to 0 to disable." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "見ていないペインでコマンドが完了したとき、実行時間がこの秒数以上であれば通知します。0にすると無効になります。" + "value": "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in Programa. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in Programa." } } } }, - "settings.notifications.longCommandThreshold.title": { + "settings.browser.httpAllowlist": { "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Long Command Notification" - } - }, - "ja": { + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "長時間コマンドの通知" + "value": "HTTP Hosts Allowed in Embedded Browser" } } } }, - "settings.notifications.longCommandThreshold.unit": { + "settings.browser.httpAllowlist.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "sec" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "秒" + "value": "Controls which HTTP (non-HTTPS) hosts can open in Programa without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me." } } } }, - "settings.notifications.sound.custom.choose.button": { + "settings.browser.httpAllowlist.hint": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose..." + "value": "One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me)." } } } }, - "settings.notifications.sound.custom.choose.prompt": { + "settings.browser.httpAllowlist.save": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose" + "value": "Save" } } } }, - "settings.notifications.sound.custom.choose.title": { + "settings.browser.interceptOpen": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose Notification Sound" + "value": "Intercept open http(s) in Terminal" } } } }, - "settings.notifications.sound.custom.clear.button": { + "settings.browser.interceptOpen.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear" + "value": "When off, `open https://...` and `open http://...` always use your default browser." } } } }, - "settings.notifications.sound.custom.error.title": { + "settings.browser.openTerminalLinks": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Custom Notification Sound Error" + "value": "Open Terminal Links in Programa Browser" } } } }, - "settings.notifications.sound.custom.file.none": { + "settings.browser.openTerminalLinks.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No file selected" + "value": "When off, links clicked in terminal output open in your default browser." } } } }, - "settings.notifications.sound.custom.status.empty": { + "settings.browser.searchEngine": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Choose a custom audio file first." + "value": "Default Search Engine" } } } }, - "settings.notifications.sound.custom.status.missingExtensionPrefix": { + "settings.browser.searchEngine.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File needs an extension: " + "value": "Used by the browser address bar when input is not a URL." } } } }, - "settings.notifications.sound.custom.status.missingFilePrefix": { + "settings.browser.searchSuggestions": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File not found: " + "value": "Show Search Suggestions" } } } }, - "settings.notifications.sound.custom.status.prepareFailed": { + "settings.browser.theme": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." + "value": "Browser Theme" } } } }, - "settings.notifications.sound.custom.status.ready": { + "settings.browser.theme.subtitleForced": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ready for notifications." + "value": "%@ forces that color scheme for compatible pages." } } } }, - "settings.notifications.sound.custom.status.readyConverted": { + "settings.browser.theme.subtitleSystem": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Prepared for notifications (converted to CAF)." + "value": "System follows app and macOS appearance." } } } }, - "settings.notifications.sound.subtitle": { + "settings.material.contentBackground": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sound played when a notification arrives." + "value": "Content Background" } } } }, - "settings.notifications.sound.title": { + "settings.material.fullScreenUI": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notification Sound" + "value": "Full Screen UI" } } } }, - "settings.phone.devices.empty": { + "settings.material.headerView": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No devices paired yet." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "まだペア設定されたデバイスはありません。" + "value": "Header View" } } } }, - "settings.phone.devices.pairedOn": { + "settings.material.hudWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paired %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ にペア設定" + "value": "HUD Window" } } } }, - "settings.phone.devices.revoke": { + "settings.material.liquidGlass": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Remove" + "value": "Liquid Glass (macOS 26+)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "削除" + "value": "リキッドガラス(macOS 26以降)" } } } }, - "settings.phone.devices.revokeFailed": { + "settings.material.menu": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could not remove this device. Its connection remains active. Try again." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デバイスを削除できませんでした。接続は引き続き有効です。もう一度お試しください。" + "value": "Menu" } } } }, - "settings.phone.devices.title": { + "settings.material.none": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paired Devices" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定済みデバイス" + "value": "None" } } } }, - "settings.phone.mode": { + "settings.material.popover": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mobile Companion" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "モバイル連携" + "value": "Popover" } } } }, - "settings.phone.mode.off": { + "settings.material.sheet": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Off" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "オフ" + "value": "Sheet" } } } }, - "settings.phone.mode.pairedDevicesOnly": { + "settings.material.sidebar": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paired Devices Only" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定済みデバイスのみ" + "value": "Sidebar" } } } }, - "settings.phone.mode.subtitleOff": { + "settings.material.toolTip": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The phone companion is off. No device can connect." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "モバイル連携はオフです。どのデバイスも接続できません。" + "value": "Tool Tip" } } } }, - "settings.phone.mode.subtitleOn": { + "settings.material.underWindow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paired iPhones can reach this Mac over a private peer-to-peer connection." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定されたiPhoneは、プライベートなピアツーピア接続でこのMacに接続できます。" + "value": "Under Window" } } } }, - "settings.phone.note": { + "settings.material.windowBackground": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Programa runs no server. Your phone connects directly to this Mac over a private peer-to-peer link, and only a small allow-list of methods can be sent over it." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Programaはサーバーを使用しません。お使いの電話はプライベートなピアツーピア接続でこのMacに直接接続し、許可された一部のメソッドのみを送信できます。" + "value": "Window Background" } } } }, - "settings.phone.pair.button": { + "settings.notifications.command.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair a Device…" + "value": "say \"done\"" } }, "ja": { "stringUnit": { "state": "translated", - "value": "デバイスをペア設定…" + "value": "say \"done\"" } } } }, - "settings.phone.pair.copy": { + "settings.notifications.command.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy" + "value": "Run a shell command when a notification arrives. $PROGRAMA_NOTIFICATION_TITLE, $PROGRAMA_NOTIFICATION_SUBTITLE, $PROGRAMA_NOTIFICATION_BODY are set." } }, "ja": { "stringUnit": { "state": "translated", - "value": "コピー" + "value": "通知が届いたときにシェルコマンドを実行します。$PROGRAMA_NOTIFICATION_TITLE、$PROGRAMA_NOTIFICATION_SUBTITLE、$PROGRAMA_NOTIFICATION_BODY が設定されます。" } } } }, - "settings.phone.pair.error": { + "settings.notifications.command.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could not start pairing. The phone companion may still be connecting — try again in a moment." + "value": "Notification Command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペア設定を開始できませんでした。モバイル連携がまだ接続中の可能性があります。しばらくしてから再度お試しください。" + "value": "通知コマンド" } } } }, - "settings.phone.pair.expired": { + "settings.notifications.desktop.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Expired. Start a new pairing." + "value": "Desktop Notifications" } }, "ja": { "stringUnit": { "state": "translated", - "value": "有効期限が切れました。新しいペア設定を開始してください。" + "value": "デスクトップ通知" } } } }, - "settings.phone.pair.expiresIn": { + "settings.notifications.longCommandThreshold.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Single use. Expires in %@." + "value": "Notify when a command finishes in a pane you're not looking at, if it ran at least this long. Set to 0 to disable." } }, "ja": { "stringUnit": { "state": "translated", - "value": "1回限り。%@ で失効します。" + "value": "見ていないペインでコマンドが完了したとき、実行時間がこの秒数以上であれば通知します。0にすると無効になります。" } } } }, - "settings.phone.pair.scanLabel": { + "settings.notifications.longCommandThreshold.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Scan this with the Programa iOS app's “Scan QR Code” button." + "value": "Long Command Notification" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Programa iOSアプリの「QRコードをスキャン」ボタンでこれをスキャンしてください。" + "value": "長時間コマンドの通知" } } } }, - "settings.phone.pair.subtitleV2": { + "settings.notifications.longCommandThreshold.unit": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or copy the code it shows." + "value": "sec" } }, "ja": { "stringUnit": { "state": "translated", - "value": "一度だけ使える5分間のペアリング枠を開きます。Programa iOSアプリでQRコードをスキャンするか、表示されたコードをコピーしてください。" + "value": "秒" } } } }, - "settings.phone.pair.title": { + "settings.notifications.sound.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair a Device" + "value": "Sound played when a notification arrives." } - }, - "ja": { + } + } + }, + "settings.notifications.sound.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "デバイスをペア設定" + "value": "Notification Sound" } } } @@ -11221,23 +9404,6 @@ } } }, - "settings.section.phone": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Phone" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "電話" - } - } - } - }, "settings.section.ports": { "extractionState": "manual", "localizations": { @@ -11681,23 +9847,6 @@ } } }, - "settings.tab.phone": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Phone" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "スマートフォン" - } - } - } - }, "settings.tab.shortcuts": { "extractionState": "manual", "localizations": { @@ -12100,17 +10249,6 @@ } } }, - "shortcut.toggleReactGrab.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Toggle React Grab" - } - } - } - }, "shortcut.toggleSidebar.label": { "extractionState": "manual", "localizations": { @@ -12891,83 +11029,6 @@ } } }, - "sidebar.remote.help.connected": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSH connected to %@" - } - } - } - }, - "sidebar.remote.help.connecting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSH connecting to %@" - } - } - } - }, - "sidebar.remote.help.disconnected": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSH disconnected from %@" - } - } - } - }, - "sidebar.remote.help.error": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSH error for %@" - } - } - } - }, - "sidebar.remote.help.errorWithDetail": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSH error for %@: %@" - } - } - } - }, - "sidebar.remote.help.targetFallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "remote host" - } - } - } - }, - "sidebar.remote.subtitleFallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SSH workspace" - } - } - } - }, "sidebar.workspace.accessibilityHint": { "extractionState": "manual", "localizations": { diff --git a/Resources/programa.sdef b/Resources/programa.sdef deleted file mode 100644 index 8c54adb7..00000000 --- a/Resources/programa.sdef +++ /dev/null @@ -1,192 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd"> - -<dictionary title="Programa Scripting Dictionary"> - <suite name="Programa Suite" code="Cmux" description="Programa scripting support."> - <class name="application" code="capp" description="The Programa application."> - <cocoa class="NSApplication"/> - <property name="name" code="pnam" type="text" access="r" description="The name of the application."/> - <property name="frontmost" code="pisf" type="boolean" access="r" description="Is this the active application?"> - <cocoa key="isActive"/> - </property> - <property name="front window" code="CMFW" type="window" access="r" description="The frontmost Programa window."> - <cocoa key="frontWindow"/> - </property> - <property name="version" code="vers" type="text" access="r" description="The version number of the application."/> - <responds-to command="perform action"> - <cocoa method="handlePerformActionScriptCommand:"/> - </responds-to> - <responds-to command="new window"> - <cocoa method="handleNewWindowScriptCommand:"/> - </responds-to> - <responds-to command="new tab"> - <cocoa method="handleNewTabScriptCommand:"/> - </responds-to> - <responds-to command="quit"> - <cocoa method="handleQuitScriptCommand:"/> - </responds-to> - - <element type="window" access="r"> - <cocoa key="scriptWindows"/> - </element> - - <element type="terminal" access="r"> - <cocoa key="terminals"/> - </element> - </class> - - <class name="window" code="CMwn" plural="windows" description="A Programa window containing one or more workspaces."> - <cocoa class="ProgramaScriptWindow"/> - <property name="id" code="ID " type="text" access="r" description="Stable ID for this window."/> - <property name="name" code="pnam" type="text" access="r" description="The title of the window."> - <cocoa key="title"/> - </property> - <property name="selected tab" code="CMsT" type="tab" access="r" description="The selected workspace in this window."> - <cocoa key="selectedTab"/> - </property> - <responds-to command="activate window"> - <cocoa method="handleActivateWindowCommand:"/> - </responds-to> - <responds-to command="close window"> - <cocoa method="handleCloseWindowCommand:"/> - </responds-to> - <element type="tab" access="r"> - <cocoa key="tabs"/> - </element> - <element type="terminal" access="r"> - <cocoa key="terminals"/> - </element> - </class> - - <class name="tab" code="CMtb" plural="tabs" description="A Programa workspace."> - <cocoa class="ProgramaScriptTab"/> - <property name="id" code="ID " type="text" access="r" description="Stable ID for this workspace."/> - <property name="name" code="pnam" type="text" access="r" description="The title of the workspace."> - <cocoa key="title"/> - </property> - <property name="index" code="pidx" type="integer" access="r" description="1-based index of this workspace in its window."/> - <property name="selected" code="CMsl" type="boolean" access="r" description="Whether this workspace is selected in its window."/> - <property name="focused terminal" code="CMfT" type="terminal" access="r" description="The currently focused terminal panel in this workspace."> - <cocoa key="focusedTerminal"/> - </property> - <responds-to command="select tab"> - <cocoa method="handleSelectTabCommand:"/> - </responds-to> - <responds-to command="close tab"> - <cocoa method="handleCloseTabCommand:"/> - </responds-to> - <element type="terminal" access="r"> - <cocoa key="terminals"/> - </element> - </class> - - <class name="terminal" code="CMtr" plural="terminals" description="An individual terminal panel."> - <cocoa class="ProgramaScriptTerminal"/> - <property name="id" code="ID " type="text" access="r" description="Stable ID for this terminal panel."/> - <property name="name" code="pnam" type="text" access="r" description="Current terminal title."> - <cocoa key="title"/> - </property> - <property name="working directory" code="CMwd" type="text" access="r" description="Current working directory for the terminal process."> - <cocoa key="workingDirectory"/> - </property> - <responds-to command="split"> - <cocoa method="handleSplitCommand:"/> - </responds-to> - <responds-to command="focus"> - <cocoa method="handleFocusCommand:"/> - </responds-to> - <responds-to command="close"> - <cocoa method="handleCloseCommand:"/> - </responds-to> - </class> - - <enumeration name="split direction" code="CMSD" description="Direction for a new split."> - <enumerator name="right" code="GSrt" description="Split to the right."/> - <enumerator name="left" code="GSlf" description="Split to the left."/> - <enumerator name="down" code="GSdn" description="Split downward."/> - <enumerator name="up" code="GSup" description="Split upward."/> - </enumeration> - - <command name="perform action" code="CmuxPfAc" description="Perform a Ghostty action string on a terminal."> - <direct-parameter type="text" description="The Ghostty action string."/> - <parameter name="on" code="CMoT" type="terminal" description="Target terminal."> - <cocoa key="on"/> - </parameter> - <result type="boolean" description="True when the action was performed."/> - </command> - - <command name="new window" code="CmuxNWin" description="Create a new Programa window."> - <result type="window" description="The newly created window."/> - </command> - - <command name="new tab" code="CmuxNTab" description="Create a new workspace."> - <parameter name="in" code="CMtW" type="window" optional="yes" description="Target window for the new workspace."> - <cocoa key="window"/> - </parameter> - <result type="tab" description="The newly created workspace."/> - </command> - - <command name="split" code="CmuxSplt" description="Split a terminal in the given direction."> - <direct-parameter type="specifier" description="The terminal to split."/> - <parameter name="direction" code="CMpd" type="split direction" description="The direction to split."> - <cocoa key="direction"/> - </parameter> - <result type="terminal" description="The newly created terminal."/> - </command> - - <command name="focus" code="CmuxFcus" description="Focus a terminal, bringing its window to the front."> - <direct-parameter type="specifier" description="The terminal to focus."/> - </command> - - <command name="close" code="CmuxClos" description="Close a terminal."> - <direct-parameter type="specifier" description="The terminal to close."/> - </command> - - <command name="activate window" code="CmuxAcWn" description="Activate a Programa window, bringing it to the front."> - <direct-parameter type="specifier" description="The window to activate."/> - </command> - - <command name="select tab" code="CmuxSlTb" description="Select a workspace in its window."> - <direct-parameter type="specifier" description="The workspace to select."/> - </command> - - <command name="close tab" code="CmuxClTb" description="Close a workspace."> - <direct-parameter type="specifier" description="The workspace to close."/> - </command> - - <command name="close window" code="CmuxClWn" description="Close a window."> - <direct-parameter type="specifier" description="The window to close."/> - </command> - - <command name="input text" code="CmuxInTx" description="Input text to a terminal as if it was pasted."> - <cocoa class="ProgramaScriptInputTextCommand"/> - <direct-parameter type="text" description="The text to input."/> - <parameter name="to" code="CMiT" type="terminal" description="The terminal to input text to."> - <cocoa key="terminal"/> - </parameter> - </command> - </suite> - - <suite name="Standard Suite" code="????" description="Common classes and commands for all applications."> - <command name="count" code="corecnte" description="Return the number of elements of a particular class within an object."> - <cocoa class="NSCountCommand"/> - <access-group identifier="*"/> - <direct-parameter type="specifier" requires-access="r" description="The objects to be counted."/> - <parameter name="each" code="kocl" type="type" optional="yes" description="The class of objects to be counted." hidden="yes"> - <cocoa key="ObjectClass"/> - </parameter> - <result type="integer" description="The count."/> - </command> - - <command name="exists" code="coredoex" description="Verify that an object exists."> - <cocoa class="NSExistsCommand"/> - <access-group identifier="*"/> - <direct-parameter type="any" requires-access="r" description="The object(s) to check."/> - <result type="boolean" description="Did the object(s) exist?"/> - </command> - - <command name="quit" code="aevtquit" description="Quit the application."> - <cocoa class="NSQuitCommand"/> - </command> - </suite> -</dictionary> diff --git a/Resources/settings.schema.json b/Resources/settings.schema.json index 48f37891..8c830e08 100644 --- a/Resources/settings.schema.json +++ b/Resources/settings.schema.json @@ -145,15 +145,10 @@ }, "sound": { "type": "string", - "enum": ["default", "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", "custom_file", "none"], + "enum": ["default", "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink", "none"], "default": "default", "description": "Notification sound preset." }, - "customSoundFilePath": { - "type": "string", - "default": "", - "description": "Local path to the custom notification sound file." - }, "command": { "type": "string", "default": "", @@ -284,6 +279,11 @@ "default": true, "description": "Enable Programa integration hooks for Claude Code." }, + "openBrowserWithAgentSplits": { + "type": "boolean", + "default": false, + "description": "Open a browser split beside the terminal when a new agent workspace is created." + }, "claudeBinaryPath": { "type": "string", "default": "", @@ -378,15 +378,10 @@ "default": ["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"], "description": "HTTP hosts allowed in the embedded browser without a warning prompt." }, - "showImportHintOnBlankTabs": { - "type": "boolean", - "default": true, - "description": "Show the browser import hint on blank tabs." - }, "proxy": { "type": "object", "additionalProperties": false, - "description": "Route the embedded browser through a proxy. Applied only when no remote relay endpoint is active (the relay takes precedence). Requires host and port; type defaults to socks5.", + "description": "Route the embedded browser through a proxy. Requires host and port; type defaults to socks5.", "properties": { "host": { "type": "string", @@ -489,8 +484,7 @@ "hideFind", "useSelectionForFind", "toggleBrowserDeveloperTools", - "showBrowserJavaScriptConsole", - "toggleReactGrab" + "showBrowserJavaScriptConsole" ] }, "additionalProperties": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index be260a83..08d0a4d2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1126,12 +1126,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser object: nil, suspensionBehavior: .deliverImmediately ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleReactGrabDidCopySelection(_:)), - name: .reactGrabDidCopySelection, - object: nil - ) NotificationCenter.default.addObserver( self, selector: #selector(handleDesignModeDidCapture(_:)), @@ -1223,18 +1217,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // windows explicitly per test and never want a phantom default window lingering in // `mainWindowContexts` for the lifetime of the test run, so exclude that case here. if isRunningUnderXCTest, !isEmbeddedUnitTestHost(env) { - if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { - UserDefaults.standard.set( - rawShow == "1", - forKey: BrowserImportHintSettings.showOnBlankTabsKey - ) - } - if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { - UserDefaults.standard.set( - rawDismissed == "1", - forKey: BrowserImportHintSettings.dismissedKey - ) - } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self else { return } if NSApp.windows.isEmpty { @@ -1249,25 +1231,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in - guard let self else { return } - _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in - self?.openPreferencesWindow( - debugSource: "uiTest.browserImportHint", - navigationTarget: .browser - ) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - } } #endif } @@ -1569,8 +1532,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } sessionAutosave.stopSessionAutosaveTimer() TerminalController.shared.stop() - MobileBridgeListener.shared.stop() - VSCodeServeWebController.shared.stop() BrowserProfileStore.shared.flushPendingSaves() notificationStore?.clearAll() enableSuddenTerminationIfNeeded() @@ -4501,86 +4462,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - @discardableResult - func openDirectoryInInlineVSCode( - _ directoryURL: URL, - tabManager preferredTabManager: TabManager? = nil - ) -> Bool { - guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else { - return false - } - - let targetTabManager = preferredTabManager - ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.open.target")?.tabManager - guard let targetTabManager else { - return false - } - - let targetWorkspaceId = targetTabManager.selectedWorkspace?.id - ?? targetTabManager.tabs.first?.id - ?? targetTabManager.addWorkspace(select: true).id - let normalizedDirectoryURL = directoryURL.standardizedFileURL - - VSCodeServeWebController.shared.ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in - guard let serveWebURL, - let openFolderURL = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: serveWebURL, - directoryPath: normalizedDirectoryURL.path - ) else { - NSSound.beep() - return - } - - guard targetTabManager.openBrowser( - inWorkspace: targetWorkspaceId, - url: openFolderURL, - preferSplitRight: true - ) != nil else { - NSSound.beep() - return - } - } - - return true - } - - func showOpenFolderInInlineVSCodePanel(tabManager preferredTabManager: TabManager? = nil) { - guard TerminalDirectoryOpenTarget.vscodeInline.isAvailable() else { - NSSound.beep() - return - } - - let targetTabManager = preferredTabManager - ?? preferredMainWindowContextForWorkspaceCreation(debugSource: "inlineVSCode.panel.target")?.tabManager - guard let targetTabManager else { - NSSound.beep() - return - } - - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.title = String( - localized: "menu.file.openFolderInVSCodeInline.panelTitle", - defaultValue: "Open Folder in VS Code (Inline)" - ) - panel.prompt = String( - localized: "menu.file.openFolderInVSCodeInline.panelPrompt", - defaultValue: "Open in VS Code" - ) - if let cwd = targetTabManager.selectedWorkspace?.currentDirectory, - !cwd.isEmpty { - panel.directoryURL = URL(fileURLWithPath: cwd) - } - - if panel.runModal() == .OK, - let url = panel.url, - !openDirectoryInInlineVSCode(url, tabManager: targetTabManager) { - NSSound.beep() - } - } - @objc func openWindow( _ pasteboard: NSPasteboard, userData: String?, @@ -4798,6 +4679,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser initialTerminalInput: "claude\n", select: true ) + context.tabManager.openCompanionBrowserSplitIfEnabled(for: workspace) return workspace.id } @@ -5543,68 +5425,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser pasteboard.setString(payload, forType: .string) } - @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { - let browserPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID - guard let workspaceId = notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, - let returnPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, - let content = notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingNotificationFields " + - "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + - "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" - ) -#endif - return - } - - guard let manager = tabManagerFor(tabId: workspaceId), - let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" - ) -#endif - return - } - - guard workspace.terminalPanel(for: returnPanelId) != nil else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + - "focused=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - return - } - -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" - ) -#endif - manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusRequested " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) - } @objc private func handleDesignModeDidCapture(_ notification: Notification) { guard let workspaceId = notification.userInfo?[DesignModeNotificationKey.workspaceId] as? UUID, @@ -5651,13 +5471,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser preferredPanelId: UUID? = nil, beforeSend: (() -> Void)? = nil ) { - let isReactGrabPasteback = preferredPanelId != nil + let isDesignModePasteback = preferredPanelId != nil #if DEBUG let initialTargetPanel = Self.resolveTerminalPanelForTextSend( in: tab, preferredPanelId: preferredPanelId ) - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.send.start " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -5675,7 +5495,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ), terminalPanel.surface.surface != nil { #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.send.immediate " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -5686,7 +5506,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser beforeSend?() terminalPanel.sendText(text) #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.send.sent " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -5722,7 +5542,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser preferredPanelId: preferredPanelId ) #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.finishIfReady " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -5741,7 +5561,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser beforeSend?() terminalPanel.sendText(text) #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.send.sent " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -5755,7 +5575,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser .map { _ in () } .sink { _ in #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.panelsChanged " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -5765,7 +5585,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser #endif finishIfReady() } - if isReactGrabPasteback { + if isDesignModePasteback { focusObserver = NotificationCenter.default.addObserver( forName: .ghosttyDidFocusSurface, object: nil, @@ -5817,7 +5637,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser workspaceId == tab.id else { return } let surfaceId = note.userInfo?["surfaceId"] as? UUID #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.surfaceReadyEvent " + "workspace=\(Self.debugShortId(workspaceId)) " + @@ -5838,7 +5658,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { if !resolved { #if DEBUG - if isReactGrabPasteback { + if isDesignModePasteback { dlog( "reactGrab.pasteback h2.send.timeout " + "workspace=\(Self.debugShortId(tab.id)) " + @@ -6861,7 +6681,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private static let appShortcutPrecedenceOrderAfterLegacyTabNavigation: [KeyboardShortcutSettings.Action] = [ .newSurface, .openBrowser, .openReview, .focusBrowserAddressBar, .browserBack, .browserForward, .browserReload, - .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, .toggleReactGrab, .browserZoomIn, + .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, .browserZoomIn, .browserZoomOut, .browserZoomReset, .find, .findNext, .findPrevious, .hideFind, .useSelectionForFind, .reopenClosedBrowserPanel, ] @@ -7021,8 +6841,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return handleToggleBrowserDeveloperToolsShortcutAction(event: event) case .showBrowserJavaScriptConsole: return handleShowBrowserJavaScriptConsoleShortcutAction(event: event) - case .toggleReactGrab: - return handleToggleReactGrabShortcutAction(event: event) case .browserZoomIn: return handleBrowserZoomInShortcutAction(event: event) case .browserZoomOut: @@ -7626,13 +7444,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return true } - private func handleToggleReactGrabShortcutAction(event: NSEvent) -> Bool? { - guard matchConfiguredShortcut(event: event, action: .toggleReactGrab) else { return nil } - let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false - if !didHandle { NSSound.beep() } - return true - } - // Browser zoom actions. Shared by browserZoomIn/Out/Reset -- they differ only by which // TabManager zoom method to invoke. private func handleBrowserZoomShortcutAction( diff --git a/Sources/AppleScriptSupport.swift b/Sources/AppleScriptSupport.swift deleted file mode 100644 index 5144d89e..00000000 --- a/Sources/AppleScriptSupport.swift +++ /dev/null @@ -1,714 +0,0 @@ -import AppKit - -private enum AppleScriptStrings { - static let disabled = String( - localized: "applescript.error.disabled", - defaultValue: "AppleScript is disabled by the macos-applescript configuration." - ) - static let missingAction = String( - localized: "applescript.error.missingAction", - defaultValue: "Missing action string." - ) - static let missingInputText = String( - localized: "applescript.error.missingInputText", - defaultValue: "Missing input text." - ) - static let missingTerminalTarget = String( - localized: "applescript.error.missingTerminalTarget", - defaultValue: "Missing terminal target." - ) - static let missingSplitDirection = String( - localized: "applescript.error.missingSplitDirection", - defaultValue: "Missing or unknown split direction." - ) - static let windowUnavailable = String( - localized: "applescript.error.windowUnavailable", - defaultValue: "Window is no longer available." - ) - static let workspaceUnavailable = String( - localized: "applescript.error.workspaceUnavailable", - defaultValue: "Workspace is no longer available." - ) - static let terminalUnavailable = String( - localized: "applescript.error.terminalUnavailable", - defaultValue: "Terminal is no longer available." - ) - static let failedToCreateWindow = String( - localized: "applescript.error.failedToCreateWindow", - defaultValue: "Failed to create window." - ) - static let failedToCreateWorkspace = String( - localized: "applescript.error.failedToCreateWorkspace", - defaultValue: "Failed to create workspace." - ) - static let failedToCreateSplit = String( - localized: "applescript.error.failedToCreateSplit", - defaultValue: "Failed to create split." - ) -} - -private extension String { - var fourCharCode: UInt32 { - utf8.reduce(0) { ($0 << 8) + UInt32($1) } - } -} - -private extension Workspace { - func scriptingTerminalPanels() -> [TerminalPanel] { - var results: [TerminalPanel] = [] - var seen: Set<UUID> = [] - - for panelId in sidebarOrderedPanelIds() { - guard seen.insert(panelId).inserted, - let terminal = terminalPanel(for: panelId) else { - continue - } - results.append(terminal) - } - - let remaining = panels.values - .compactMap { $0 as? TerminalPanel } - .sorted { $0.id.uuidString < $1.id.uuidString } - - for terminal in remaining where seen.insert(terminal.id).inserted { - results.append(terminal) - } - - return results - } -} - -@MainActor -extension NSApplication { - var isAppleScriptEnabled: Bool { - // cmux always enables AppleScript — the underlying Ghostty fork - // doesn't have the macos-applescript config key yet (added in - // upstream ghostty commit 25fa58143, 2026-03-06), so - // appleScriptAutomationEnabled() always returns false. - // Once the fork is updated, this can revert to: - // GhosttyApp.shared.appleScriptAutomationEnabled() - return true - } - - @discardableResult - func validateScript(command: NSScriptCommand) -> Bool { - guard isAppleScriptEnabled else { - command.scriptErrorNumber = errAEEventNotPermitted - command.scriptErrorString = AppleScriptStrings.disabled - return false - } - - return true - } - - @objc(scriptWindows) - var scriptWindows: [ScriptWindow] { - guard isAppleScriptEnabled, - let appDelegate = AppDelegate.shared else { - return [] - } - return appDelegate.scriptableMainWindows().map { ScriptWindow(windowId: $0.windowId) } - } - - @objc(frontWindow) - var frontWindow: ScriptWindow? { - scriptWindows.first - } - - @objc(valueInScriptWindowsWithUniqueID:) - func valueInScriptWindows(uniqueID: String) -> ScriptWindow? { - guard isAppleScriptEnabled, - let windowId = UUID(uuidString: uniqueID), - let appDelegate = AppDelegate.shared, - appDelegate.scriptableMainWindow(windowId: windowId) != nil else { - return nil - } - return ScriptWindow(windowId: windowId) - } - - @objc(terminals) - var terminals: [ScriptTerminal] { - guard isAppleScriptEnabled, - let appDelegate = AppDelegate.shared else { - return [] - } - - return appDelegate.scriptableMainWindows() - .flatMap { state in - state.tabManager.tabs.flatMap { workspace in - workspace.scriptingTerminalPanels().map { - ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id) - } - } - } - } - - @objc(valueInTerminalsWithUniqueID:) - func valueInTerminals(uniqueID: String) -> ScriptTerminal? { - guard isAppleScriptEnabled, - let terminalId = UUID(uuidString: uniqueID), - let appDelegate = AppDelegate.shared else { - return nil - } - - for state in appDelegate.scriptableMainWindows() { - for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil { - return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId) - } - } - - return nil - } - - @objc(handlePerformActionScriptCommand:) - func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? { - guard validateScript(command: command) else { return nil } - - guard let action = command.directParameter as? String else { - command.scriptErrorNumber = errAEParamMissed - command.scriptErrorString = AppleScriptStrings.missingAction - return nil - } - - guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else { - command.scriptErrorNumber = errAEParamMissed - command.scriptErrorString = AppleScriptStrings.missingTerminalTarget - return nil - } - - return NSNumber(value: terminal.perform(action: action)) - } - - @objc(handleNewWindowScriptCommand:) - func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? { - guard validateScript(command: command) else { return nil } - - guard let appDelegate = AppDelegate.shared else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.failedToCreateWindow - return nil - } - - let windowId = appDelegate.createMainWindow() - return ScriptWindow(windowId: windowId) - } - - @objc(handleNewTabScriptCommand:) - func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? { - guard validateScript(command: command) else { return nil } - - guard let appDelegate = AppDelegate.shared else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace - return nil - } - - if let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow { - guard let workspaceId = appDelegate.addWorkspace(windowId: targetWindow.windowId, bringToFront: false) else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace - return nil - } - return ScriptTab(windowId: targetWindow.windowId, tabId: workspaceId) - } - - if let frontWindow = scriptWindows.first, - let workspaceId = appDelegate.addWorkspace(windowId: frontWindow.windowId, bringToFront: false) { - return ScriptTab(windowId: frontWindow.windowId, tabId: workspaceId) - } - - let windowId = appDelegate.createMainWindow() - return ScriptWindow(windowId: windowId).selectedTab - } - - @objc(handleQuitScriptCommand:) - func handleQuitScriptCommand(_ command: NSScriptCommand) { - guard validateScript(command: command) else { return } - terminate(nil) - } -} - -@MainActor -@objc(ProgramaScriptWindow) -final class ScriptWindow: NSObject { - let windowId: UUID - - init(windowId: UUID) { - self.windowId = windowId - } - - private var state: AppDelegate.ScriptableMainWindowState? { - AppDelegate.shared?.scriptableMainWindow(windowId: windowId) - } - - @objc(id) - var idValue: String { - guard NSApp.isAppleScriptEnabled else { return "" } - return windowId.uuidString - } - - @objc(title) - var title: String { - guard NSApp.isAppleScriptEnabled, - let state else { - return "" - } - - let windowTitle = state.window?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !windowTitle.isEmpty { - return windowTitle - } - - return state.tabManager.selectedWorkspace?.title ?? "" - } - - @objc(tabs) - var tabs: [ScriptTab] { - guard NSApp.isAppleScriptEnabled, - let state else { - return [] - } - return state.tabManager.tabs.map { ScriptTab(windowId: windowId, tabId: $0.id) } - } - - @objc(selectedTab) - var selectedTab: ScriptTab? { - guard NSApp.isAppleScriptEnabled, - let selectedId = state?.tabManager.selectedTabId else { - return nil - } - return ScriptTab(windowId: windowId, tabId: selectedId) - } - - @objc(terminals) - var terminals: [ScriptTerminal] { - guard NSApp.isAppleScriptEnabled, - let state else { - return [] - } - return state.tabManager.tabs.flatMap { workspace in - workspace.scriptingTerminalPanels().map { - ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id) - } - } - } - - @objc(valueInTabsWithUniqueID:) - func valueInTabs(uniqueID: String) -> ScriptTab? { - guard NSApp.isAppleScriptEnabled, - let tabId = UUID(uuidString: uniqueID), - let state, - state.tabManager.tabs.contains(where: { $0.id == tabId }) else { - return nil - } - return ScriptTab(windowId: windowId, tabId: tabId) - } - - @objc(valueInTerminalsWithUniqueID:) - func valueInTerminals(uniqueID: String) -> ScriptTerminal? { - guard NSApp.isAppleScriptEnabled, - let terminalId = UUID(uuidString: uniqueID), - let state else { - return nil - } - - for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil { - return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId) - } - - return nil - } - - @objc(handleActivateWindowCommand:) - func handleActivateWindow(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard AppDelegate.shared?.focusScriptableMainWindow(windowId: windowId, bringToFront: true) == true else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.windowUnavailable - return nil - } - - return nil - } - - @objc(handleCloseWindowCommand:) - func handleCloseWindow(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard let window = state?.window else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.windowUnavailable - return nil - } - - window.performClose(nil) - return nil - } - - override var objectSpecifier: NSScriptObjectSpecifier? { - guard NSApp.isAppleScriptEnabled, - let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { - return nil - } - - return NSUniqueIDSpecifier( - containerClassDescription: appClassDescription, - containerSpecifier: nil, - key: "scriptWindows", - uniqueID: windowId.uuidString - ) - } -} - -@MainActor -@objc(ProgramaScriptTab) -final class ScriptTab: NSObject { - let windowId: UUID - let tabId: UUID - - init(windowId: UUID, tabId: UUID) { - self.windowId = windowId - self.tabId = tabId - } - - private var state: AppDelegate.ScriptableMainWindowState? { - AppDelegate.shared?.scriptableMainWindow(windowId: windowId) - } - - private var workspace: Workspace? { - state?.tabManager.tabs.first(where: { $0.id == tabId }) - } - - private var window: ScriptWindow { - ScriptWindow(windowId: windowId) - } - - @objc(id) - var idValue: String { - guard NSApp.isAppleScriptEnabled else { return "" } - return tabId.uuidString - } - - @objc(title) - var title: String { - guard NSApp.isAppleScriptEnabled else { return "" } - return workspace?.title ?? "" - } - - @objc(index) - var index: Int { - guard NSApp.isAppleScriptEnabled, - let state, - let idx = state.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - return 0 - } - return idx + 1 - } - - @objc(selected) - var selected: Bool { - guard NSApp.isAppleScriptEnabled else { return false } - return state?.tabManager.selectedTabId == tabId - } - - @objc(focusedTerminal) - var focusedTerminal: ScriptTerminal? { - guard NSApp.isAppleScriptEnabled, - let terminalId = workspace?.focusedTerminalPanel?.id else { - return nil - } - return ScriptTerminal(workspaceId: tabId, terminalId: terminalId) - } - - @objc(terminals) - var terminals: [ScriptTerminal] { - guard NSApp.isAppleScriptEnabled, - let workspace else { - return [] - } - return workspace.scriptingTerminalPanels().map { - ScriptTerminal(workspaceId: tabId, terminalId: $0.id) - } - } - - @objc(valueInTerminalsWithUniqueID:) - func valueInTerminals(uniqueID: String) -> ScriptTerminal? { - guard NSApp.isAppleScriptEnabled, - let workspace, - let terminalId = UUID(uuidString: uniqueID), - workspace.terminalPanel(for: terminalId) != nil else { - return nil - } - return ScriptTerminal(workspaceId: tabId, terminalId: terminalId) - } - - @objc(handleSelectTabCommand:) - func handleSelectTab(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard let state, - let workspace else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.workspaceUnavailable - return nil - } - - state.tabManager.selectWorkspace(workspace) - return nil - } - - @objc(handleCloseTabCommand:) - func handleCloseTab(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard let state, - let workspace else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.workspaceUnavailable - return nil - } - - if state.tabManager.tabs.count > 1 { - state.tabManager.closeWorkspace(workspace) - return nil - } - - guard let window = state.window else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.windowUnavailable - return nil - } - - window.performClose(nil) - return nil - } - - override var objectSpecifier: NSScriptObjectSpecifier? { - guard NSApp.isAppleScriptEnabled, - let windowClassDescription = window.classDescription as? NSScriptClassDescription, - let windowSpecifier = window.objectSpecifier else { - return nil - } - - return NSUniqueIDSpecifier( - containerClassDescription: windowClassDescription, - containerSpecifier: windowSpecifier, - key: "tabs", - uniqueID: tabId.uuidString - ) - } -} - -@MainActor -@objc(ProgramaScriptTerminal) -final class ScriptTerminal: NSObject { - let workspaceId: UUID - let terminalId: UUID - - init(workspaceId: UUID, terminalId: UUID) { - self.workspaceId = workspaceId - self.terminalId = terminalId - } - - private var state: AppDelegate.ScriptableMainWindowState? { - AppDelegate.shared?.scriptableMainWindowForTab(workspaceId) - } - - private var workspace: Workspace? { - state?.tabManager.tabs.first(where: { $0.id == workspaceId }) - } - - private var terminal: TerminalPanel? { - workspace?.terminalPanel(for: terminalId) - } - - @objc(id) - var stableID: String { - guard NSApp.isAppleScriptEnabled else { return "" } - return terminalId.uuidString - } - - @objc(title) - var title: String { - guard NSApp.isAppleScriptEnabled else { return "" } - return terminal?.displayTitle ?? "" - } - - @objc(workingDirectory) - var workingDirectory: String { - guard NSApp.isAppleScriptEnabled else { return "" } - // TerminalPanel.directory is never updated (updateDirectory is never called). - // Read from Workspace.panelDirectories instead, which is kept up to date - // via updatePanelDirectory() from OSC 7 / shell integration. - return workspace?.panelDirectories[terminalId] ?? terminal?.directory ?? "" - } - - func input(text: String) -> Bool { - guard NSApp.isAppleScriptEnabled, - let terminal else { - return false - } - terminal.sendText(text) - return true - } - - func perform(action: String) -> Bool { - guard NSApp.isAppleScriptEnabled else { return false } - return terminal?.performBindingAction(action) ?? false - } - - @objc(handleSplitCommand:) - func handleSplit(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32, - let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else { - command.scriptErrorNumber = errAEParamMissed - command.scriptErrorString = AppleScriptStrings.missingSplitDirection - return nil - } - - guard let state, - let workspace, - terminal != nil else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.terminalUnavailable - return nil - } - - guard let newPanelId = state.tabManager.newSplit(tabId: workspaceId, surfaceId: terminalId, direction: direction), - workspace.terminalPanel(for: newPanelId) != nil else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.failedToCreateSplit - return nil - } - - return ScriptTerminal(workspaceId: workspaceId, terminalId: newPanelId) - } - - @objc(handleFocusCommand:) - func handleFocus(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard let state, - let workspace, - terminal != nil else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.terminalUnavailable - return nil - } - - if let app = AppDelegate.shared { - _ = app.focusScriptableMainWindow(windowId: state.windowId, bringToFront: true) - } - state.tabManager.selectWorkspace(workspace) - workspace.focusPanel(terminalId) - return nil - } - - @objc(handleCloseCommand:) - func handleClose(_ command: NSScriptCommand) -> Any? { - guard NSApp.validateScript(command: command) else { return nil } - - guard let state, - let workspace, - terminal != nil else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.terminalUnavailable - return nil - } - - if workspace.panels.count == 1 { - if state.tabManager.tabs.count > 1 { - state.tabManager.closeWorkspace(workspace) - return nil - } - - guard let window = state.window else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.windowUnavailable - return nil - } - - window.performClose(nil) - return nil - } - - guard workspace.closePanel(terminalId, force: true) else { - command.scriptErrorNumber = errAEEventFailed - command.scriptErrorString = AppleScriptStrings.terminalUnavailable - return nil - } - - AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspaceId, surfaceId: terminalId) - return nil - } - - override var objectSpecifier: NSScriptObjectSpecifier? { - guard NSApp.isAppleScriptEnabled, - let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else { - return nil - } - - return NSUniqueIDSpecifier( - containerClassDescription: appClassDescription, - containerSpecifier: nil, - key: "terminals", - uniqueID: terminalId.uuidString - ) - } -} - -@MainActor -@objc(ProgramaScriptInputTextCommand) -final class ScriptInputTextCommand: NSScriptCommand { - override func performDefaultImplementation() -> Any? { - guard NSApp.validateScript(command: self) else { return nil } - - guard let text = directParameter as? String else { - scriptErrorNumber = errAEParamMissed - scriptErrorString = AppleScriptStrings.missingInputText - return nil - } - - guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else { - scriptErrorNumber = errAEParamMissed - scriptErrorString = AppleScriptStrings.missingTerminalTarget - return nil - } - - guard terminal.input(text: text) else { - scriptErrorNumber = errAEEventFailed - scriptErrorString = AppleScriptStrings.terminalUnavailable - return nil - } - return nil - } -} - -private enum ScriptSplitDirection { - case right - case left - case down - case up - - init?(code: UInt32) { - switch code { - case "GSrt".fourCharCode: self = .right - case "GSlf".fourCharCode: self = .left - case "GSdn".fourCharCode: self = .down - case "GSup".fourCharCode: self = .up - default: return nil - } - } - - var splitDirection: SplitDirection { - switch self { - case .right: return .right - case .left: return .left - case .down: return .down - case .up: return .up - } - } -} diff --git a/Sources/ContentView+CommandPalette.swift b/Sources/ContentView+CommandPalette.swift index 8727d1bb..661f4ff1 100644 --- a/Sources/ContentView+CommandPalette.swift +++ b/Sources/ContentView+CommandPalette.swift @@ -633,8 +633,6 @@ extension ContentView { return .toggleBrowserDeveloperTools case "palette.browserConsole": return .showBrowserJavaScriptConsole - case "palette.browserReactGrab": - return .toggleReactGrab case "palette.browserSplitRight", "palette.terminalSplitBrowserRight": return .splitBrowserRight case "palette.browserSplitDown", "palette.terminalSplitBrowserDown": diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 46818763..098dc2cc 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3508,25 +3508,6 @@ struct ContentView: View { keywords: ["open", "folder", "repository", "project", "directory"] ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.openFolderInVSCodeInline", - title: constant( - String( - localized: "command.openFolderInVSCodeInline.title", - defaultValue: "Open Folder in VS Code (Inline)…" - ) - ), - subtitle: constant( - String( - localized: "command.openFolderInVSCodeInline.subtitle", - defaultValue: "VS Code Inline" - ) - ), - keywords: ["open", "folder", "directory", "project", "vs", "code", "inline", "editor", "browser"], - when: { _ in TerminalDirectoryOpenTarget.vscodeInline.isAvailable() } - ) - ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.newTerminalTab", @@ -3977,15 +3958,6 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.browserReactGrab", - title: constant(String(localized: "command.browserReactGrab.title", defaultValue: "Toggle React Grab")), - subtitle: browserPanelSubtitle, - keywords: ["browser", "react", "grab", "inspect", "element"], - when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } - ) - ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.browserDesignMode", @@ -4072,30 +4044,6 @@ struct ContentView: View { ) ) } - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.vscodeServeWebStop", - title: constant(String(localized: "command.vscodeServeWebStop.title", defaultValue: "Stop VS Code Inline Server")), - subtitle: terminalPanelSubtitle, - keywords: ["vscode", "inline", "serve-web", "stop", "server"], - when: { context in - context.bool(CommandPaletteContextKeys.panelIsTerminal) - && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline)) - } - ) - ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.vscodeServeWebRestart", - title: constant(String(localized: "command.vscodeServeWebRestart.title", defaultValue: "Restart VS Code Inline Server")), - subtitle: terminalPanelSubtitle, - keywords: ["vscode", "inline", "serve-web", "restart", "server"], - when: { context in - context.bool(CommandPaletteContextKeys.panelIsTerminal) - && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline)) - } - ) - ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalFind", @@ -4288,11 +4236,6 @@ struct ContentView: View { } } } - registry.register(commandId: "palette.openFolderInVSCodeInline") { - DispatchQueue.main.async { - AppDelegate.shared?.showOpenFolderInInlineVSCodePanel(tabManager: tabManager) - } - } registry.register(commandId: "palette.newWindow") { AppDelegate.shared?.openNewMainWindow(nil) } @@ -4536,11 +4479,6 @@ struct ContentView: View { NSSound.beep() } } - registry.register(commandId: "palette.browserReactGrab") { - if !tabManager.toggleReactGrabFromCurrentFocus() { - NSSound.beep() - } - } registry.register(commandId: "palette.browserDesignMode") { if !tabManager.toggleDesignModeFromCurrentFocus() { NSSound.beep() @@ -4582,14 +4520,6 @@ struct ContentView: View { } } } - registry.register(commandId: "palette.vscodeServeWebStop") { - stopInlineVSCodeServeWeb() - } - registry.register(commandId: "palette.vscodeServeWebRestart") { - if !restartInlineVSCodeServeWeb() { - NSSound.beep() - } - } registry.register(commandId: "palette.terminalFind") { tabManager.startSearch() } @@ -5665,8 +5595,6 @@ struct ContentView: View { case .finder: NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) return true - case .vscodeInline: - return openFocusedDirectoryInInlineVSCode(directoryURL) default: guard let applicationURL = target.applicationURL() else { return false } let configuration = NSWorkspace.OpenConfiguration() @@ -5675,26 +5603,6 @@ struct ContentView: View { } } - private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool { - AppDelegate.shared?.openDirectoryInInlineVSCode(directoryURL, tabManager: tabManager) ?? false - } - - private func stopInlineVSCodeServeWeb() { - VSCodeServeWebController.shared.stop() - } - - private func restartInlineVSCodeServeWeb() -> Bool { - guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else { - return false - } - VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in - if serveWebURL == nil { - NSSound.beep() - } - } - return true - } - private func focusedTerminalDirectoryURL() -> URL? { guard let workspace = tabManager.selectedWorkspace else { return nil } let rawDirectory: String = { diff --git a/Sources/DebugWindows.swift b/Sources/DebugWindows.swift index a9fcd4bb..8c5a3ca0 100644 --- a/Sources/DebugWindows.swift +++ b/Sources/DebugWindows.swift @@ -721,9 +721,6 @@ private struct BrowserProfilePopoverDebugView: View { Text(String(localized: "browser.profile.new", defaultValue: "New Profile...")) .font(.system(size: 12)) - - Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) - .font(.system(size: 12)) } .padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw)) .padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw)) diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 51c240be..35936d21 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -312,82 +312,11 @@ class GhosttyApp { return } - let preparedContent = TerminalImageTransferPlanner.prepare( - pasteboard: pasteboard, - mode: .paste - ) - - switch preparedContent { + switch TerminalPasteboardPlanner.plan(pasteboard: pasteboard, mode: .paste) { case .reject: completeClipboardRequest(with: "") case .insertText(let text): completeClipboardRequest(with: text) - case .fileURLs(let fileURLs): - let operation = TerminalImageTransferOperation() - terminalSurface.hostedView.beginImageTransferIndicator( - for: operation, - onCancel: { - completeClipboardRequest(with: "") - } - ) - - let target = terminalSurface.resolvedImageTransferTarget() - let plan = TerminalImageTransferPlanner.plan( - fileURLs: fileURLs, - target: target - ) - - TerminalImageTransferPlanner.execute( - plan: plan, - operation: operation, - uploadWorkspaceRemote: { fileURLs, operation, finish in - guard let workspace = MainActor.assumeIsolated({ - terminalSurface.owningWorkspace() - }) else { - finish(.failure(NSError(domain: "programa.remote.paste", code: 3))) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - return - } - workspace.uploadDroppedFilesForRemoteTerminal( - fileURLs, - operation: operation, - completion: { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - } - ) - }, - uploadDetectedSSH: { session, fileURLs, operation, finish in - session.uploadDroppedFiles( - fileURLs, - operation: operation, - completion: { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - } - ) - }, - insertText: { text in - MainActor.assumeIsolated { - terminalSurface.hostedView.endImageTransferIndicator( - for: operation - ) - } - completeClipboardRequest(with: text) - }, - onFailure: { _ in - MainActor.assumeIsolated { - terminalSurface.hostedView.endImageTransferIndicator( - for: operation - ) - } - NSSound.beep() -#if DEBUG - dlog("terminal.remotePasteUpload.failed surface=\(callbackSurfaceId.uuidString.prefix(5))") -#endif - completeClipboardRequest(with: "") - } - ) } } } @@ -1459,14 +1388,6 @@ class GhosttyApp { return found && enabled } - func appleScriptAutomationEnabled() -> Bool { - guard let config else { return false } - var enabled = false - let key = "macos-applescript" - _ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8))) - return enabled - } - func shellIntegrationMode() -> String { guard let config else { return "detect" } var value: UnsafePointer<Int8>? diff --git a/Sources/GhosttyNSView.swift b/Sources/GhosttyNSView.swift index 299bf5bc..cfa0276a 100644 --- a/Sources/GhosttyNSView.swift +++ b/Sources/GhosttyNSView.swift @@ -23,7 +23,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { }() internal enum DropPlan: Equatable { case insertText(String) - case uploadFiles([URL]) case reject } diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index 6146ced0..895e8c51 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -124,15 +124,8 @@ final class GhosttySurfaceScrollView: NSView { private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeIconView: NSImageView private let keyboardCopyModeBadgeLabel: NSTextField - private let imageTransferIndicatorContainerView: NSView - private let imageTransferIndicatorView: NSVisualEffectView - private let imageTransferIndicatorSpinner: NSProgressIndicator - private let imageTransferCancelButton: NSButton private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>? private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem? - private var imageTransferIndicatorShowWorkItem: DispatchWorkItem? - private var activeImageTransferOperation: TerminalImageTransferOperation? - private var activeImageTransferCancelHandler: (() -> Void)? private var lastSearchOverlayStateID: ObjectIdentifier? private var searchOverlayMutationGeneration: UInt64 = 0 #if DEBUG @@ -388,10 +381,6 @@ final class GhosttySurfaceScrollView: NSView { keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero) keyboardCopyModeBadgeIconView = NSImageView(frame: .zero) keyboardCopyModeBadgeLabel = NSTextField(labelWithString: terminalKeyboardCopyModeIndicatorText) - imageTransferIndicatorContainerView = NSView(frame: .zero) - imageTransferIndicatorView = NSVisualEffectView(frame: .zero) - imageTransferIndicatorSpinner = NSProgressIndicator(frame: .zero) - imageTransferCancelButton = NSButton(frame: .zero) // No AppKit scrollers: Ghostty owns scrollback and all wheel gestures // are forwarded to the surface (see GhosttyScrollView.scrollWheel), so // the NSScrollView scroller is vestigial. Leaving the vertical scroller @@ -536,71 +525,6 @@ final class GhosttySurfaceScrollView: NSView { keyboardCopyModeBadgeContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), ]) - imageTransferIndicatorContainerView.translatesAutoresizingMaskIntoConstraints = false - imageTransferIndicatorContainerView.wantsLayer = true - imageTransferIndicatorContainerView.layer?.masksToBounds = false - imageTransferIndicatorContainerView.layer?.shadowColor = NSColor.black.cgColor - imageTransferIndicatorContainerView.layer?.shadowOpacity = 0.18 - imageTransferIndicatorContainerView.layer?.shadowRadius = 8 - imageTransferIndicatorContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2) - imageTransferIndicatorView.translatesAutoresizingMaskIntoConstraints = false - imageTransferIndicatorView.wantsLayer = true - imageTransferIndicatorView.material = .hudWindow - imageTransferIndicatorView.blendingMode = .withinWindow - imageTransferIndicatorView.state = .active - imageTransferIndicatorView.layer?.cornerRadius = 16 - imageTransferIndicatorView.layer?.masksToBounds = true - imageTransferIndicatorView.layer?.borderWidth = 1 - imageTransferIndicatorView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor - imageTransferIndicatorView.alphaValue = 0.95 - imageTransferIndicatorSpinner.translatesAutoresizingMaskIntoConstraints = false - imageTransferIndicatorSpinner.style = .spinning - imageTransferIndicatorSpinner.controlSize = .small - imageTransferIndicatorSpinner.isDisplayedWhenStopped = false - imageTransferCancelButton.translatesAutoresizingMaskIntoConstraints = false - imageTransferCancelButton.isBordered = false - imageTransferCancelButton.imagePosition = .imageOnly - imageTransferCancelButton.image = NSImage( - systemSymbolName: "xmark.circle.fill", - accessibilityDescription: String(localized: "common.cancel", defaultValue: "Cancel") - ) - imageTransferCancelButton.contentTintColor = NSColor.secondaryLabelColor - imageTransferCancelButton.toolTip = String(localized: "common.cancel", defaultValue: "Cancel") - imageTransferCancelButton.setAccessibilityLabel( - String(localized: "common.cancel", defaultValue: "Cancel") - ) - imageTransferCancelButton.target = self - imageTransferCancelButton.action = #selector(handleImageTransferCancel) - imageTransferIndicatorContainerView.addSubview(imageTransferIndicatorView) - imageTransferIndicatorView.addSubview(imageTransferIndicatorSpinner) - imageTransferIndicatorView.addSubview(imageTransferCancelButton) - NSLayoutConstraint.activate([ - imageTransferIndicatorView.topAnchor.constraint(equalTo: imageTransferIndicatorContainerView.topAnchor), - imageTransferIndicatorView.bottomAnchor.constraint(equalTo: imageTransferIndicatorContainerView.bottomAnchor), - imageTransferIndicatorView.leadingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.leadingAnchor), - imageTransferIndicatorView.trailingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.trailingAnchor), - imageTransferIndicatorSpinner.leadingAnchor.constraint(equalTo: imageTransferIndicatorView.leadingAnchor, constant: 10), - imageTransferIndicatorSpinner.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor), - imageTransferIndicatorSpinner.widthAnchor.constraint(equalToConstant: 14), - imageTransferIndicatorSpinner.heightAnchor.constraint(equalToConstant: 14), - imageTransferCancelButton.leadingAnchor.constraint(equalTo: imageTransferIndicatorSpinner.trailingAnchor, constant: 6), - imageTransferCancelButton.trailingAnchor.constraint(equalTo: imageTransferIndicatorView.trailingAnchor, constant: -8), - imageTransferCancelButton.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor), - imageTransferCancelButton.widthAnchor.constraint(equalToConstant: 16), - imageTransferCancelButton.heightAnchor.constraint(equalToConstant: 16), - imageTransferIndicatorSpinner.topAnchor.constraint(equalTo: imageTransferIndicatorView.topAnchor, constant: 8), - imageTransferIndicatorSpinner.bottomAnchor.constraint(equalTo: imageTransferIndicatorView.bottomAnchor, constant: -8), - ]) - imageTransferIndicatorContainerView.isHidden = true - addSubview(imageTransferIndicatorContainerView) - NSLayoutConstraint.activate([ - imageTransferIndicatorContainerView.topAnchor.constraint( - equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor, - constant: 8 - ), - imageTransferIndicatorContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), - ]) - scrollView.contentView.postsBoundsChangedNotifications = true observers.append(NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, @@ -720,7 +644,6 @@ final class GhosttySurfaceScrollView: NSView { windowObservers.forEach { NotificationCenter.default.removeObserver($0) } windowObserverGeneration &+= 1 deferredSearchOverlayMutationWorkItem?.cancel() - imageTransferIndicatorShowWorkItem?.cancel() dropZoneOverlayView.removeFromSuperview() cancelFocusRequest() } @@ -1114,29 +1037,6 @@ final class GhosttySurfaceScrollView: NSView { DispatchQueue.main.async(execute: work) } - private func cancelImageTransferIndicatorShow() { - imageTransferIndicatorShowWorkItem?.cancel() - imageTransferIndicatorShowWorkItem = nil - } - - private func updateImageTransferIndicatorZOrder(relativeTo overlay: NSView?) { - guard !imageTransferIndicatorContainerView.isHidden else { return } - if let overlay, overlay.superview === self { - addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: overlay) - return - } - if keyboardCopyModeBadgeContainerView.superview === self, - !keyboardCopyModeBadgeContainerView.isHidden { - addSubview( - imageTransferIndicatorContainerView, - positioned: .above, - relativeTo: keyboardCopyModeBadgeContainerView - ) - return - } - addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: nil) - } - private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) { guard !keyboardCopyModeBadgeContainerView.isHidden else { return } if let overlay, overlay.superview === self { @@ -1144,65 +1044,6 @@ final class GhosttySurfaceScrollView: NSView { } else { addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil) } - updateImageTransferIndicatorZOrder(relativeTo: overlay) - } - - @objc private func handleImageTransferCancel() { - guard let operation = activeImageTransferOperation else { return } - let onCancel = activeImageTransferCancelHandler - guard operation.cancel() else { return } - endImageTransferIndicator(for: operation) - onCancel?() - } - - func beginImageTransferIndicator( - for operation: TerminalImageTransferOperation, - onCancel: @escaping () -> Void - ) { - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - self?.beginImageTransferIndicator(for: operation, onCancel: onCancel) - } - return - } - - cancelImageTransferIndicatorShow() - activeImageTransferOperation = operation - activeImageTransferCancelHandler = onCancel - imageTransferIndicatorSpinner.stopAnimation(nil) - imageTransferIndicatorContainerView.isHidden = true - - let work = DispatchWorkItem { [weak self] in - guard let self else { return } - guard self.activeImageTransferOperation === operation else { return } - guard !operation.isCancelled else { return } - self.imageTransferIndicatorShowWorkItem = nil - self.imageTransferIndicatorSpinner.startAnimation(nil) - self.imageTransferIndicatorContainerView.isHidden = false - self.updateImageTransferIndicatorZOrder(relativeTo: self.searchOverlayHostingView) - } - imageTransferIndicatorShowWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work) - } - - func endImageTransferIndicator(for operation: TerminalImageTransferOperation?) { - if !Thread.isMainThread { - DispatchQueue.main.async { [weak self] in - self?.endImageTransferIndicator(for: operation) - } - return - } - - if let operation, - activeImageTransferOperation !== operation { - return - } - - cancelImageTransferIndicatorShow() - activeImageTransferOperation = nil - activeImageTransferCancelHandler = nil - imageTransferIndicatorSpinner.stopAnimation(nil) - imageTransferIndicatorContainerView.isHidden = true } private func makeSearchOverlayRootView( diff --git a/Sources/GhosttyTerminalView+DragDrop.swift b/Sources/GhosttyTerminalView+DragDrop.swift index e91649be..041d72b7 100644 --- a/Sources/GhosttyTerminalView+DragDrop.swift +++ b/Sources/GhosttyTerminalView+DragDrop.swift @@ -23,206 +23,39 @@ import UniformTypeIdentifiers extension GhosttyNSView { fileprivate static func escapeDropForShell(_ value: String) -> String { - TerminalImageTransferPlanner.escapeForShell(value) + TerminalPasteboardPlanner.escapeForShell(value) } - static func dropPlanForTesting( - pasteboard: NSPasteboard, - isRemoteTerminalSurface: Bool - ) -> DropPlan { - let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local - switch TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .drop, - target: target - ) { + static func dropPlanForTesting(pasteboard: NSPasteboard) -> DropPlan { + switch TerminalPasteboardPlanner.plan(pasteboard: pasteboard, mode: .drop) { case .insertText(let text): return .insertText(text) - case .uploadFiles(let fileURLs, _): - return .uploadFiles(fileURLs) case .reject: return .reject } } - static func performRemoteDropUploadForTesting( - upload: (@escaping (Result<[String], Error>) -> Void) -> Void, - sendText: @escaping (String) -> Void, - onFailure: @escaping () -> Void - ) { - upload { result in - switch result { - case .success(let remotePaths): - let content = remotePaths - .map { Self.escapeDropForShell($0) } - .joined(separator: " ") - guard !content.isEmpty else { - onFailure() - return - } - sendText(content) - case .failure: - onFailure() - } - } - } - - @discardableResult - static func handleDropForTesting( - pasteboard: NSPasteboard, - isRemoteTerminalSurface: Bool, - uploadRemote: ([URL], @escaping (Result<[String], Error>) -> Void) -> Void, - sendText: @escaping (String) -> Void, - onFailure: @escaping () -> Void - ) -> Bool { - let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .drop, - target: target - ) - guard plan != .reject else { return false } - - TerminalImageTransferPlanner.execute( - plan: plan, - uploadWorkspaceRemote: { urls, _, finish in - uploadRemote(urls) { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(urls) - } - }, - uploadDetectedSSH: { _, _, _, finish in - finish(.failure(NSError(domain: "programa.remote.drop", code: 4))) - }, - insertText: sendText, - onFailure: { _ in onFailure() } - ) - return true - } - - private func executeImageTransferPlan( - _ plan: TerminalImageTransferPlan, - operation: TerminalImageTransferOperation? = nil, - onCancel: @escaping () -> Void = {} - ) -> Bool { - guard plan != .reject else { return false } - - let operation = operation ?? { - if case .uploadFiles = plan { - return TerminalImageTransferOperation() - } - return nil - }() - - if let operation { - terminalSurface?.hostedView.beginImageTransferIndicator( - for: operation, - onCancel: onCancel - ) - } - - TerminalImageTransferPlanner.execute( - plan: plan, - operation: operation, - uploadWorkspaceRemote: { [weak self] fileURLs, operation, finish in - guard let workspace = MainActor.assumeIsolated({ - self?.terminalSurface?.owningWorkspace() - }) else { - finish(.failure(NSError(domain: "programa.remote.drop", code: 3))) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - return - } - workspace.uploadDroppedFilesForRemoteTerminal( - fileURLs, - operation: operation, - completion: { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - } - ) - }, - uploadDetectedSSH: { session, fileURLs, operation, finish in - session.uploadDroppedFiles( - fileURLs, - operation: operation, - completion: { result in - finish(result) - GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) - } - ) - }, - insertText: { [weak self] text in - let send = { - if let operation { - self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation) - } - // Use the text/paste path (ghostty_surface_text) instead of the key event - // path (ghostty_surface_key) so bracketed paste mode is triggered and the - // insertion is instant, matching upstream Ghostty behaviour. - self?.terminalSurface?.sendText(text) - } - if Thread.isMainThread { - send() - } else { - DispatchQueue.main.async(execute: send) - } - }, - onFailure: { [weak self] _ in - if let operation { - self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation) - } - DispatchQueue.main.async { - NSSound.beep() -#if DEBUG - dlog("terminal.remoteDropUpload.failed surface=\(self?.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") -#endif - } - } - ) - return true - } - - private func resolvedImageTransferTarget() -> TerminalImageTransferTarget { - MainActor.assumeIsolated { - terminalSurface?.resolvedImageTransferTarget() ?? .local - } - } - func handleDroppedFileURLs(_ urls: [URL]) -> Bool { - executePreparedImageTransfer( - .fileURLs(urls), - onCancel: {} - ) + insertPlannedTransfer(TerminalPasteboardPlanner.plan(fileURLs: urls)) } @discardableResult func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool { - executePreparedImageTransfer( - TerminalImageTransferPlanner.prepare( - pasteboard: pasteboard, - mode: .drop - ), - onCancel: {} + insertPlannedTransfer( + TerminalPasteboardPlanner.plan(pasteboard: pasteboard, mode: .drop) ) } @discardableResult - private func executePreparedImageTransfer( - _ preparedContent: TerminalImageTransferPreparedContent, - onCancel: @escaping () -> Void + private func insertPlannedTransfer( + _ insertion: TerminalPasteboardInsertion ) -> Bool { - switch preparedContent { + switch insertion { case .reject: return false case .insertText(let text): terminalSurface?.sendText(text) return true - case .fileURLs(let fileURLs): - let plan = TerminalImageTransferPlanner.plan( - fileURLs: fileURLs, - target: resolvedImageTransferTarget() - ) - return executeImageTransferPlan(plan, onCancel: onCancel) } } diff --git a/Sources/GhosttyTerminalView+Mouse.swift b/Sources/GhosttyTerminalView+Mouse.swift index e5d2d9b7..112e38e8 100644 --- a/Sources/GhosttyTerminalView+Mouse.swift +++ b/Sources/GhosttyTerminalView+Mouse.swift @@ -138,8 +138,7 @@ extension GhosttyNSView { guard let surface = surface else { return nil } guard let termSurface = terminalSurface, - let workspace = termSurface.owningWorkspace(), - !workspace.isRemoteTerminalSurface(termSurface.id) else { return nil } + let workspace = termSurface.owningWorkspace() else { return nil } guard let cwd = resolvedWordPathWorkingDirectory(workspace: workspace, terminalSurface: termSurface) else { return nil diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f671c917..a2715674 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -77,7 +77,6 @@ enum KeyboardShortcutSettings { case useSelectionForFind case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole - case toggleReactGrab case openReview var id: String { rawValue } @@ -139,7 +138,6 @@ enum KeyboardShortcutSettings { case .useSelectionForFind: return String(localized: "menu.find.useSelectionForFind", defaultValue: "Use Selection for Find") case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") - case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab") case .openReview: return String(localized: "shortcut.openReview.label", defaultValue: "Open Review Panel") } } @@ -260,8 +258,6 @@ enum KeyboardShortcutSettings { case .showBrowserJavaScriptConsole: // Safari default: Show JavaScript Console. return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) - case .toggleReactGrab: - return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) case .openReview: // Shipped without a default binding: Cmd+Shift+R (the plan's suggested default) // is already taken by `.renameWorkspace` (see docs/keyboard-shortcuts.md), and no diff --git a/Sources/MobileBridge/MobileBridgeListener.swift b/Sources/MobileBridge/MobileBridgeListener.swift deleted file mode 100644 index f0cf0393..00000000 --- a/Sources/MobileBridge/MobileBridgeListener.swift +++ /dev/null @@ -1,710 +0,0 @@ -import Bonsplit -import Foundation -import IrohLib - -/// Programa's mobile-bridge ALPN (M1). Deliberately distinct from any other -/// iroh ALPN this Mac might speak, mirroring -/// `tools/mobile-spike/Sources/iroh-spike/App.swift`'s `spikeALPN`. -private let mobileBridgeALPN = Data("programa/mobile-bridge/1".utf8) - -private let mobileBridgePairingWindowDuration: Duration = .seconds(300) - -private extension Duration { - /// `Duration` has no direct `TimeInterval` conversion; used to derive a - /// wall-clock `Date` expiry from `mobileBridgePairingWindowDuration` for - /// display in Settings, without hardcoding the 300s figure a second time. - var timeIntervalValue: TimeInterval { - let parts = components - return TimeInterval(parts.seconds) + TimeInterval(parts.attoseconds) / 1e18 - } -} - -/// Everything Settings needs to show a pairing invitation: the two payloads -/// to transfer plus the wall-clock deadline for the live countdown. -struct MobileBridgePairingInfo: Sendable { - let ticket: String - let token: String - let expiresAt: Date -} - -enum MobileBridgeDeviceRevocationOutcome: Sendable, Equatable { - case revoked - case persistenceFailed -} - -final class MobileBridgeConnectionRegistry: @unchecked Sendable { - typealias CloseAction = @Sendable () -> Void - - struct PendingAdmissionLease: Sendable { - fileprivate let id: UUID - fileprivate let listenerGeneration: UInt64 - } - - struct AdmissionTicket: Sendable { - fileprivate let endpointId: String - fileprivate let endpointGeneration: UInt64 - fileprivate let listenerGeneration: UInt64 - fileprivate let pendingAdmissionID: UUID? - } - - struct ListenerLifecycle: Sendable { - fileprivate let listenerGeneration: UInt64 - } - - enum RegistrationResult: Sendable { - case registered(superseded: [CloseAction]) - case rejected(CloseAction) - } - - private struct IdentifiedPendingAdmission { - let endpointId: String - let endpointGeneration: UInt64 - let listenerGeneration: UInt64 - let close: CloseAction - } - - private static let maximumPendingAdmissions = 10 - private static let maximumLiveConnections = 10 - private static let noOpClose: CloseAction = {} - - private let lock = NSLock() - private var isAccepting = false - private var listenerGeneration: UInt64 = 0 - private var endpointGenerations: [String: UInt64] = [:] - private var anonymousPendingAdmissions: [UUID: UInt64] = [:] - private var identifiedPendingAdmissions: [UUID: IdentifiedPendingAdmission] = [:] - private var liveRecords: [String: [ObjectIdentifier: CloseAction]] = [:] - - func start() -> ListenerLifecycle { - lock.withLock { - if !isAccepting { - listenerGeneration &+= 1 - isAccepting = true - } - return ListenerLifecycle(listenerGeneration: listenerGeneration) - } - } - - func beginAdmission(endpointId: String, lifecycle: ListenerLifecycle) -> AdmissionTicket? { - lock.withLock { - guard isAccepting, lifecycle.listenerGeneration == listenerGeneration else { return nil } - return AdmissionTicket( - endpointId: endpointId, - endpointGeneration: endpointGenerations[endpointId] ?? 0, - listenerGeneration: listenerGeneration, - pendingAdmissionID: nil - ) - } - } - - func reservePending(lifecycle: ListenerLifecycle) -> PendingAdmissionLease? { - lock.withLock { - guard isAccepting, - lifecycle.listenerGeneration == listenerGeneration, - anonymousPendingAdmissions.count + identifiedPendingAdmissions.count - < Self.maximumPendingAdmissions - else { - return nil - } - - let lease = PendingAdmissionLease( - id: UUID(), - listenerGeneration: listenerGeneration - ) - anonymousPendingAdmissions[lease.id] = listenerGeneration - return lease - } - } - - func identifyPending( - _ lease: PendingAdmissionLease, - endpointId: String, - close: @escaping CloseAction - ) -> AdmissionTicket? { - lock.withLock { - guard let reservedGeneration = anonymousPendingAdmissions.removeValue(forKey: lease.id), - reservedGeneration == lease.listenerGeneration, - isAccepting, - lease.listenerGeneration == listenerGeneration, - !identifiedPendingAdmissions.values.contains(where: { $0.endpointId == endpointId }) - else { - return nil - } - - let endpointGeneration = endpointGenerations[endpointId] ?? 0 - identifiedPendingAdmissions[lease.id] = IdentifiedPendingAdmission( - endpointId: endpointId, - endpointGeneration: endpointGeneration, - listenerGeneration: listenerGeneration, - close: close - ) - return AdmissionTicket( - endpointId: endpointId, - endpointGeneration: endpointGeneration, - listenerGeneration: listenerGeneration, - pendingAdmissionID: lease.id - ) - } - } - - func expireAdmission(_ ticket: AdmissionTicket) -> CloseAction? { - claimPendingAdmission(ticket) - } - - @discardableResult - func abandonPending(_ lease: PendingAdmissionLease) -> Bool { - lock.withLock { - guard anonymousPendingAdmissions[lease.id] == lease.listenerGeneration else { return false } - anonymousPendingAdmissions[lease.id] = nil - return true - } - } - - func abandonAdmission(_ ticket: AdmissionTicket) -> CloseAction? { - claimPendingAdmission(ticket) - } - - func registerIfCurrent( - connectionID: ObjectIdentifier, - ticket: AdmissionTicket, - close: @escaping CloseAction, - beforeRegister: () -> Bool = { true } - ) -> RegistrationResult { - lock.withLock { - let candidateClose: CloseAction - if let pendingAdmissionID = ticket.pendingAdmissionID { - guard let pending = identifiedPendingAdmissions[pendingAdmissionID] else { - return .rejected(Self.noOpClose) - } - guard isAccepting, - ticket.listenerGeneration == listenerGeneration, - ticket.endpointGeneration == (endpointGenerations[ticket.endpointId] ?? 0), - pending.endpointId == ticket.endpointId, - pending.endpointGeneration == ticket.endpointGeneration, - pending.listenerGeneration == ticket.listenerGeneration - else { - identifiedPendingAdmissions[pendingAdmissionID] = nil - return .rejected(pending.close) - } - candidateClose = pending.close - } else { - guard isAccepting, - ticket.listenerGeneration == listenerGeneration, - ticket.endpointGeneration == (endpointGenerations[ticket.endpointId] ?? 0) - else { - return .rejected(close) - } - candidateClose = close - } - - let liveConnectionCount = liveRecords.values.reduce(into: 0) { - $0 += $1.count - } - let replacesCurrentEndpoint = liveRecords[ticket.endpointId]?.isEmpty == false - guard liveConnectionCount < Self.maximumLiveConnections || replacesCurrentEndpoint else { - if let pendingAdmissionID = ticket.pendingAdmissionID { - identifiedPendingAdmissions[pendingAdmissionID] = nil - } - return .rejected(candidateClose) - } - guard beforeRegister() else { - if let pendingAdmissionID = ticket.pendingAdmissionID { - identifiedPendingAdmissions[pendingAdmissionID] = nil - } - return .rejected(candidateClose) - } - - if let pendingAdmissionID = ticket.pendingAdmissionID { - identifiedPendingAdmissions[pendingAdmissionID] = nil - } - let superseded = liveRecords.removeValue(forKey: ticket.endpointId).map { - Array($0.values) - } ?? [] - liveRecords[ticket.endpointId] = [connectionID: candidateClose] - return .registered(superseded: superseded) - } - } - - func unregister(connectionID: ObjectIdentifier, endpointId: String) { - lock.withLock { - liveRecords[endpointId]?[connectionID] = nil - if liveRecords[endpointId]?.isEmpty == true { - liveRecords[endpointId] = nil - } - } - } - - func revoke(endpointId: String, beforeClaim: () -> Void = {}) -> [CloseAction] { - lock.withLock { - endpointGenerations[endpointId] = (endpointGenerations[endpointId] ?? 0) &+ 1 - beforeClaim() - - let pendingIDs = identifiedPendingAdmissions.compactMap { id, admission in - admission.endpointId == endpointId ? id : nil - } - let pendingActions = pendingIDs.compactMap { - identifiedPendingAdmissions.removeValue(forKey: $0)?.close - } - let liveActions = liveRecords.removeValue(forKey: endpointId).map { - Array($0.values) - } ?? [] - return pendingActions + liveActions - } - } - - func stop() -> [CloseAction] { - lock.withLock { - if isAccepting { - isAccepting = false - listenerGeneration &+= 1 - } - let actions = identifiedPendingAdmissions.values.map(\.close) - + liveRecords.values.flatMap { Array($0.values) } - anonymousPendingAdmissions.removeAll(keepingCapacity: true) - identifiedPendingAdmissions.removeAll(keepingCapacity: true) - liveRecords.removeAll(keepingCapacity: true) - return actions - } - } - - private func claimPendingAdmission(_ ticket: AdmissionTicket) -> CloseAction? { - lock.withLock { - guard let pendingAdmissionID = ticket.pendingAdmissionID, - let pending = identifiedPendingAdmissions[pendingAdmissionID], - pending.endpointId == ticket.endpointId, - pending.endpointGeneration == ticket.endpointGeneration, - pending.listenerGeneration == ticket.listenerGeneration - else { - return nil - } - identifiedPendingAdmissions[pendingAdmissionID] = nil - return pending.close - } - } -} - -/// Owns the in-process iroh endpoint that lets a paired iPhone reach this -/// Mac's terminal control dispatch without the user ever running the -/// `tools/mobile-spike bridge` CLI in a terminal. Ported from -/// `tools/mobile-spike/Sources/iroh-spike/Bridge.swift`'s `runBridge`/ -/// `handleBridgeConnection`, with connection admission/relay delegated to -/// `MobileBridgeSession` (which substitutes an in-process `socketpair` for -/// the CLI's `UnixSocketPipe`). -/// -/// All accept/relay work runs off the main thread. `start()` returns -/// immediately and binds the endpoint on a detached background task, so -/// toggling this on from Settings (or at app launch) never blocks the UI -/// or app startup; bind failures are logged, never thrown to the caller. -final class MobileBridgeListener: @unchecked Sendable { - static let shared = MobileBridgeListener() - - private let stateLock = NSLock() - private let endpointBinder: @Sendable () async throws -> Endpoint - private var endpoint: Endpoint? - private var acceptTask: Task<Void, Never>? - private var pairingWindow: MobileBridgePairingWindow? - private var isStarting = false - private var generation: UInt64 = 0 - private let connectionRegistry = MobileBridgeConnectionRegistry() - - init( - endpointBinder: @escaping @Sendable () async throws -> Endpoint = { - try await MobileBridgeListener.bindEndpoint() - } - ) { - self.endpointBinder = endpointBinder - } - - /// Starts the endpoint if it is not already running or starting. - /// Idempotent. Never blocks the caller. - /// - /// Also assigns `TerminalController.shared.tabManager` so admitted - /// phone sessions can dispatch commands -- this does NOT start or - /// otherwise touch Programa's real Unix socket listener/access mode - /// (see `MobileBridgeSession`'s doc comment for the one runtime - /// precondition this implies). - @MainActor - func start(tabManager: TabManager) { - TerminalController.shared.tabManager = tabManager - - let startState = stateLock.withLock { - guard endpoint == nil, !isStarting else { - return nil as (generation: UInt64, lifecycle: MobileBridgeConnectionRegistry.ListenerLifecycle)? - } - isStarting = true - generation &+= 1 - return ( - generation: generation, - lifecycle: connectionRegistry.start() - ) - } - guard let startState else { return } - - Task { [weak self] in - await self?.bindAndAccept( - generation: startState.generation, - lifecycle: startState.lifecycle - ) - } - } - - /// Stops the listener and closes the endpoint. Safe to call whether or - /// not the listener is currently running. - func stop() { - let stoppedState = stateLock.withLock { - let task = acceptTask - let ep = endpoint - pairingWindow?.invalidate() - acceptTask = nil - endpoint = nil - pairingWindow = nil - isStarting = false - generation &+= 1 - return ( - task: task, - endpoint: ep, - closeActions: connectionRegistry.stop() - ) - } - - stoppedState.task?.cancel() - stoppedState.closeActions.forEach { $0() } - if let ep = stoppedState.endpoint { - Task { try? await ep.close() } - } - } - - /// Opens a single-use, 5-minute pairing window and returns the pairing - /// payload (ticket) and token to display in Settings. Returns `nil` if - /// the endpoint isn't bound yet (mode just enabled, still binding) -- - /// the caller should show a brief error and let the user retry. - func beginPairing() async -> MobileBridgePairingInfo? { - let snapshot = stateLock.withLock { - endpoint.map { (endpoint: $0, generation: generation) } - } - guard let snapshot else { return nil } - - let tokenBytes = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) - let tokenString = MobileBridgeBase64URL.encode(tokenBytes) - let window = MobileBridgePairingWindow(token: Data(tokenString.utf8), duration: mobileBridgePairingWindowDuration) - let expiresAt = Date().addingTimeInterval(mobileBridgePairingWindowDuration.timeIntervalValue) - - guard let ticket = try? EndpointTicket.fromAddr(addr: snapshot.endpoint.addr()) else { - window.invalidate() - return nil - } - - let published = stateLock.withLock { - guard generation == snapshot.generation, endpoint === snapshot.endpoint else { - return false - } - pairingWindow?.invalidate() - pairingWindow = window - return true - } - guard published else { - window.invalidate() - return nil - } - - return MobileBridgePairingInfo(ticket: ticket.description, token: tokenString, expiresAt: expiresAt) - } - - /// Revokes a previously paired device after its removal is durably stored: - /// reconnects are then rejected at `admit()`, and every registered - /// connection is closed so a long-lived relay session cannot keep running - /// on borrowed trust until the phone disconnects on its own. - func revoke(endpointId: String) async -> MobileBridgeDeviceRevocationOutcome { - let result = await MobileBridgeTrustedDeviceStore.shared.revokeAndClaimConnections( - endpointId: endpointId, - registry: connectionRegistry - ) - if let persistenceFailure = result.persistenceFailure { - NSLog( - "MobileBridge: failed to persist device revocation; connection remains active: %@", - persistenceFailure - ) - return .persistenceFailed - } - - result.closeActions.forEach { $0() } - return .revoked - } - - private static func bindEndpoint() async throws -> Endpoint { - let secretKey = try MobileBridgeSecretKeyStore.loadOrCreate() - // Mirrors `makeEndpointOptions` in - // `tools/mobile-spike/Sources/iroh-spike/App.swift` exactly -- - // see that file's doc comment for why each field matters - // (`presetN0()`, `RelayMode.defaultMode()`, `0.0.0.0:0` bind, - // `portMappingEnabled: true`). - let options = EndpointOptions( - preset: presetN0(), - bindAddr: "0.0.0.0:0", - secretKey: secretKey, - alpns: [mobileBridgeALPN], - relayMode: RelayMode.defaultMode(), - portMappingEnabled: true, - deferNatTraversalUntilAuthorized: true, - initialMaxConcurrentBiStreams: 0, - initialMaxConcurrentUniStreams: 0 - ) - return try await Endpoint.bind(options: options) - } - - private func bindAndAccept( - generation: UInt64, - lifecycle: MobileBridgeConnectionRegistry.ListenerLifecycle - ) async { - do { - let ep = try await endpointBinder() - - let published = stateLock.withLock { - guard self.generation == generation else { return false } - self.endpoint = ep - self.isStarting = false - return true - } - guard published else { - // `stop()` (or a subsequent `start()`) ran while we were - // binding -- this bind is stale, close it without publishing - // state. - try? await ep.close() - return - } - -#if DEBUG - // Log the dialable ticket, not just the node id: without it there is - // no way to reach this bridge except by opening Settings and - // starting a pairing window, which makes the bridge untestable from - // a script. The ticket is an address, not a secret -- admission - // still requires the pairing token or an allowlisted device. - let debugTicket = (try? EndpointTicket.fromAddr(addr: ep.addr()))?.description ?? "<unavailable>" - dlog("mobileBridge.listening node=\(ep.id()) ticket=\(debugTicket)") -#endif - - let task = Task { [weak self] in - guard let self else { return } - await self.acceptLoop( - endpoint: ep, - generation: generation, - lifecycle: lifecycle - ) - } - let installed = stateLock.withLock { - guard self.generation == generation else { return false } - acceptTask = task - return true - } - if !installed { - task.cancel() - } - } catch { - NSLog("MobileBridge: failed to bind endpoint: %@", "\(error)") - stateLock.withLock { - if self.generation == generation { - isStarting = false - } - } - } - } - - private func acceptLoop( - endpoint: Endpoint, - generation: UInt64, - lifecycle: MobileBridgeConnectionRegistry.ListenerLifecycle - ) async { - while let incoming = await endpoint.acceptNext() { - let stillCurrent = stateLock.withLock { self.generation == generation } - guard stillCurrent else { - try? await incoming.refuse() - break - } - - guard let pendingLease = connectionRegistry.reservePending(lifecycle: lifecycle) else { - try? await incoming.refuse() - let remainsCurrent = stateLock.withLock { self.generation == generation } - if !remainsCurrent { - break - } - continue - } - - let registry = connectionRegistry - let pendingDeadline = Self.startPendingAdmissionDeadline( - registry: registry, - lease: pendingLease, - timeout: .seconds(15) - ) { - try? await incoming.refuse() - } - Task { [weak self] in - guard let self else { - pendingDeadline.cancel() - if registry.abandonPending(pendingLease) { - try? await incoming.refuse() - } - return - } - await self.handleIncoming( - incoming, - pendingLease: pendingLease, - pendingDeadline: pendingDeadline - ) - } - } - } - - static func startPendingAdmissionDeadline( - registry: MobileBridgeConnectionRegistry, - lease: MobileBridgeConnectionRegistry.PendingAdmissionLease, - timeout: Duration, - onTimeout: @escaping @Sendable () async -> Void - ) -> Task<Void, Never> { - Task { - do { - try await Task.sleep(for: timeout) - } catch { - return - } - guard registry.abandonPending(lease) else { return } - await onTimeout() - } - } - - private func handleIncoming( - _ incoming: Incoming, - pendingLease initialPendingLease: MobileBridgeConnectionRegistry.PendingAdmissionLease, - pendingDeadline: Task<Void, Never> - ) async { - var pendingLease: MobileBridgeConnectionRegistry.PendingAdmissionLease? = initialPendingLease - var admissionTicket: MobileBridgeConnectionRegistry.AdmissionTicket? - var admissionDeadline: Task<Void, Never>? - defer { - pendingDeadline.cancel() - admissionDeadline?.cancel() - if let admissionTicket { - connectionRegistry.abandonAdmission(admissionTicket)?() - } else if let pendingLease { - connectionRegistry.abandonPending(pendingLease) - } - } - - do { - let accepting = try await incoming.accept() - let remoteALPN = try await accepting.alpn() - guard remoteALPN == mobileBridgeALPN else { -#if DEBUG - dlog("mobileBridge.rejected reason=unexpected_alpn") -#endif - return - } - - let connection = try await accepting.connect() - let connectionClose = MobileBridgeCloseOnce { - try? connection.close( - errorCode: 0, - reason: Data("bridge session closed".utf8) - ) - } - let closeAction: MobileBridgeConnectionRegistry.CloseAction = { - connectionClose.close() - } - defer { closeAction() } - - let idString = connection.remoteId().description - let ticket = connectionRegistry.identifyPending( - initialPendingLease, - endpointId: idString, - close: closeAction - ) - pendingLease = nil - pendingDeadline.cancel() - guard let ticket else { - closeAction() - return - } - admissionTicket = ticket - - let registry = connectionRegistry - admissionDeadline = Task { - do { - try await Task.sleep(for: .seconds(15)) - } catch { - return - } - registry.expireAdmission(ticket)?() - } - - try connection.setMaxConcurrentBiStreams(count: 1) - try connection.setMaxConcurrentUniStreams(count: 0) - - let stream = try await connection.acceptBi() - let reader = MobileBridgeStreamLineReader(stream: stream.recv()) - let writer = MobileBridgeFrameWriter(stream: stream.send()) - - let window = stateLock.withLock { pairingWindow } - - guard let admissionOutcome = try await MobileBridgeSession.admit( - idString: idString, - reader: reader, - writer: writer, - pairingWindow: window - ) else { - return - } - - let connectionID = ObjectIdentifier(connection) - let registrationResult: MobileBridgeConnectionRegistry.RegistrationResult - switch admissionOutcome { - case .trusted: - registrationResult = connectionRegistry.registerIfCurrent( - connectionID: connectionID, - ticket: ticket, - close: closeAction - ) - case .paired(let label): - registrationResult = await MobileBridgeTrustedDeviceStore.shared.registerPairedIfCurrent( - endpointId: idString, - label: label, - registry: connectionRegistry, - connectionID: connectionID, - ticket: ticket, - close: closeAction - ) - } - admissionDeadline?.cancel() - - switch registrationResult { - case .registered(let superseded): - superseded.forEach { $0() } - case .rejected(let close): - close() - return - } - defer { - connectionRegistry.unregister(connectionID: connectionID, endpointId: idString) - } - - if case .paired = admissionOutcome { - try await writer.writeLine(Data(#"{"ok":true,"paired":true}"#.utf8)) - } - try await connection.authorizeNatTraversal() - -#if DEBUG - dlog("mobileBridge.connected id=\(idString)") -#endif - await MobileBridgeSession.relay( - reader: reader, - writer: writer, - idString: idString, - closeRemote: closeAction - ) -#if DEBUG - dlog("mobileBridge.disconnected id=\(idString)") -#endif - } catch { - NSLog("MobileBridge: connection handling error: %@", "\(error)") - } - } -} diff --git a/Sources/MobileBridge/MobileBridgePairingCode.swift b/Sources/MobileBridge/MobileBridgePairingCode.swift deleted file mode 100644 index c75dc9aa..00000000 --- a/Sources/MobileBridge/MobileBridgePairingCode.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation - -/// Combines the mobile-bridge pairing ticket + token into a single URL so a -/// tester can transfer both by scanning one QR code (or pasting one string) -/// instead of hand-copying two long opaque strings between devices. -/// -/// `programa-pair` (not the bare `programa`) was chosen as the scheme after -/// confirming no `CFBundleURLTypes`/URL scheme is registered anywhere in this -/// repo today (`Resources/Info.plist`, `Sources/`, `ios/ProgramaSpike/project.yml`) -- -/// either name would have been free, but the more specific scheme makes the -/// pairing URL self-describing and leaves `programa://` open for some other -/// future purpose without a collision. -/// -/// Kept in sync by hand with -/// `ios/ProgramaSpike/ProgramaSpike/PairingCode.swift` -- the two app -/// targets share no module, so there is no compiler-enforced tie between -/// them. Any format change here must be mirrored there. -enum MobileBridgePairingCode { - static let scheme = "programa-pair" - private static let host = "pair" - - /// The only format version this build understands. Bumping it is a breaking - /// change for every already-installed phone: the companion ships through - /// TestFlight and lags the Mac app, so a Mac that emits `v=2` while a phone - /// still understands `v=1` must be rejected loudly rather than mis-parsed. - /// Ship phone-side support for a new version BEFORE the Mac starts emitting it. - static let currentVersion = "1" - - /// Builds the combined `programa-pair://pair?v=1&t=<ticket>&k=<token>` - /// URL. `URLComponents` percent-encodes both query values, so neither - /// the iroh ticket nor the base64url token needs manual escaping. - static func makeURL(ticket: String, token: String) -> URL? { - var components = URLComponents() - components.scheme = scheme - components.host = host - components.queryItems = [ - URLQueryItem(name: "v", value: currentVersion), - URLQueryItem(name: "t", value: ticket), - URLQueryItem(name: "k", value: token), - ] - return components.url - } - - struct Parsed { - let ticket: String - let token: String - } - - /// Parses a combined pairing code back into its ticket/token. Returns - /// `nil` for anything that isn't a well-formed `programa-pair://` URL - /// with both `t` and `k` present -- callers should fall back to treating - /// the input as a bare ticket in that case. - static func parse(_ string: String) -> Parsed? { - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard let components = URLComponents(string: trimmed), - components.scheme?.lowercased() == scheme - else { return nil } - guard let items = components.queryItems else { return nil } - // Reject an unrecognised version rather than reading `t`/`k` out of a - // format we do not actually understand. A missing `v` is also rejected: - // every code this app has ever emitted carries one. - guard items.first(where: { $0.name == "v" })?.value == currentVersion else { return nil } - guard - let ticket = items.first(where: { $0.name == "t" })?.value, !ticket.isEmpty, - let token = items.first(where: { $0.name == "k" })?.value, !token.isEmpty - else { return nil } - return Parsed(ticket: ticket, token: token) - } -} diff --git a/Sources/MobileBridge/MobileBridgePush.swift b/Sources/MobileBridge/MobileBridgePush.swift deleted file mode 100644 index 5d2b59ee..00000000 --- a/Sources/MobileBridge/MobileBridgePush.swift +++ /dev/null @@ -1,268 +0,0 @@ -import Bonsplit -import CloudKit -import Foundation - -/// M3: publishes a small, low-sensitivity agent-activity summary to the user's own iCloud -/// private database via CloudKit, so a `CKQuerySubscription` on the paired iPhone can wake it -/// with a push while the mobile bridge's iroh connection is dead (backgrounded/suspended -- -/// see `MobileBridgeListener`). No server Programa operates: each user's Mac writes only to -/// *their own* private database, under container `iCloud.com.darkroom.programa`. The record -/// carries nothing sensitive beyond counts and a workspace title -- never prompt/output text, -/// mirroring the mobile bridge's own "never put prompt or output text in a notification -/// payload" rule (see `plans/golden-tumbling-gray.md`'s "Security implications" section). -/// -/// Gated on: -/// - `MobileBridgeSettings` being anything other than `.off` -- users who never turned on the -/// phone companion generate zero CloudKit traffic. -/// - `CKContainer.accountStatus == .available` -- silently no-ops (logs once) otherwise, e.g. -/// not signed into iCloud on this Mac. Note this gate does *not* cover a missing container -/// entitlement: `CKContainer(identifier:)` traps before `accountStatus` is ever consulted, -/// which is why the container is built lazily behind the guards (see `makeContainer`) and -/// why `releaseProvisioningComplete` must move in lockstep with the entitlement itself. -/// -/// Trigger point: called directly from `Workspace.updatePanelAgentState` / -/// `clearPanelAgentState` / `resetSidebarContext` (`Workspace+SidebarTelemetry.swift`), -/// alongside the existing `SocketEventBroadcaster.shared.publishAgentState(...)` calls at each -/// site. Chosen over adding an observer mechanism to `SocketEventBroadcaster` itself: that -/// class is shared, hot-path infrastructure for the entire v2 subscription fan-out, and giving -/// it a second consumer channel is a materially bigger, riskier change than three one-line -/// calls at the existing telemetry funnel that already fires on every transition. -final class MobileBridgePush: @unchecked Sendable { - static let shared = MobileBridgePush() - - static let containerIdentifier = "iCloud.com.darkroom.programa" - static let recordType = "AgentStatus" - /// Stable record name (default zone, no custom `recordID.zoneID`) so every write updates - /// the same record in place rather than accumulating new ones in the user's iCloud quota. - static let recordName = "agent-status-summary" - - /// Never write more than once every this many seconds -- every CloudKit write is a push - /// delivered to the user's phone. - private static let minimumWriteInterval: TimeInterval = 5 - - struct Summary: Equatable { - var blockedCount: Int - var workingCount: Int - var mostRecentBlockedWorkspaceTitle: String? - } - - /// Deferred rather than built in `init`: `CKContainer(identifier:)` traps when the running - /// process is not actually entitled for that container, and `shared` is constructed on the - /// first `noteAgentStateChanged` call -- i.e. *before* any of the guards in that method can - /// run. Every ad-hoc-signed build (`codesign --sign -`, which is every Debug and CI test - /// host) is unentitled, so building the container eagerly crashed the host on the first - /// agent-state transition regardless of the kill switch below. Resolved lazily on `queue` - /// at the point of an actual write instead, which is the first moment the container is - /// genuinely needed and the only place it is used. - private let makeContainer: () -> CKContainer - private var resolvedContainer: CKContainer? - - /// Serializes all mutable state below (`trackedBlockedTitle` through - /// `didLogAccountUnavailable`). Every access to that state -- including from CloudKit's - /// own completion-handler callbacks, which run on an arbitrary system queue -- is - /// dispatched through this queue rather than guarded by a lock, since the coalescing - /// timer (`asyncAfter`) is native to `DispatchQueue` and this avoids mixing a lock with - /// queue-hopping. - private let queue = DispatchQueue(label: "com.darkroom.programa.mobileBridgePush") - - private var trackedBlockedTitle: String? - private var lastWrittenSummary: Summary? - private var pendingSummary: Summary? - private var lastWriteAt: Date = .distantPast - private var coalesceScheduled = false - private var didLogAccountUnavailable = false - - /// **Hard build-time kill switch for the CloudKit entitlement. Set to `true` on - /// 2026-07-28, together with the entitlement itself, once the release pipeline could - /// actually carry a provisioning profile that grants it.** - /// - /// What made it safe to flip: App ID `com.darkroom.programa` was registered with the - /// iCloud capability and container `iCloud.com.darkroom.programa`, a Developer ID - /// Application profile (`Programa Developer ID CloudKit`) was generated against it, and - /// that profile is supplied to CI as `APPLE_PROVISION_PROFILE_BASE64` and embedded by - /// `scripts/sign-release-app.sh` before the app is signed. Verify with - /// `scripts/verify-provision-profile.sh <app>`. - /// - /// If the entitlement is ever removed from `programa.entitlements`, or the profile secret - /// is dropped, set this back to `false` in the same change -- the two must move together. - /// The historical reason, still the reason: - /// - /// Adding `com.apple.developer.icloud-container-identifiers` / - /// `com.apple.developer.icloud-services` to `programa.entitlements` is *not* done as part - /// of this milestone: those are Apple-restricted, App-ID-level capability entitlements - /// that must be present in an embedded provisioning profile to survive AMFI's launch-time - /// check. `scripts/sign-release-app.sh` signs the notarized release build with a bare - /// `codesign --entitlements` pass and embeds no provisioning profile at all -- the exact - /// gap that bricked launch for every user in the 2026-07-14 incident (POSIX 163, see - /// `memory/restricted-entitlements-brick-app.md`) over a different restricted entitlement. - /// `main` auto-ships every green CI run, so there is no safe way to "try it and see"; - /// notarization does not catch this class of failure, only a real launch does. - /// - /// This flag is the second, independent gate (on top of `MobileBridgeSettings` and - /// `CKContainer.accountStatus`) that keeps this file inert in a shipped build even though - /// it already compiles and links against CloudKit: `CKContainer.accountStatus` alone would - /// still report `.available` on any Mac signed into iCloud, entitlement or not, so relying - /// on that gate alone is not sufficient to keep this dark. - static let releaseProvisioningComplete = true - - private init( - makeContainer: @escaping () -> CKContainer = { - CKContainer(identifier: MobileBridgePush.containerIdentifier) - } - ) { - self.makeContainer = makeContainer - } - - /// `queue`-confined. Both call sites (`checkAccountStatusAndSave`, `performSave`) already - /// run there, so the memoisation needs no further synchronisation. - private var container: CKContainer { - if let resolvedContainer { return resolvedContainer } - let container = makeContainer() - resolvedContainer = container - return container - } - - /// Call on every agent-state transition (set or clear) that already calls - /// `SocketEventBroadcaster.shared.publishAgentState`. Must be called from the main thread - /// -- it reads `TabManager.tabs` / `Workspace.aggregateAgentState` synchronously, both of - /// which ARE main-actor isolated (the compiler rejects reading them from a nonisolated - /// context). Annotated `@MainActor` to match; the call sites in - /// `Workspace+SidebarTelemetry.swift` already run there, alongside the existing - /// `publishAgentState` calls, so this adds no hop. Only the derived counts cross to the - /// background queue below -- plain Ints and an optional String. - @MainActor - func noteAgentStateChanged(workspaceId: UUID, workspaceTitle: String, changedState: AgentActivityState?) { - guard Self.releaseProvisioningComplete else { return } - guard Self.bridgeEnabled else { return } - - let workspaces = TerminalController.shared.tabManager?.tabs ?? [] - var blockedCount = 0 - var workingCount = 0 - for workspace in workspaces { - switch workspace.aggregateAgentState { - case .blocked: blockedCount += 1 - case .working: workingCount += 1 - case .idle, nil: break - } - } - let newlyBlockedWorkspaceTitle = (changedState == .blocked) ? workspaceTitle : nil - - queue.async { [weak self] in - self?.handleChange( - blockedCount: blockedCount, - workingCount: workingCount, - newlyBlockedWorkspaceTitle: newlyBlockedWorkspaceTitle - ) - } - } - - private static var bridgeEnabled: Bool { - let raw = UserDefaults.standard.string(forKey: MobileBridgeSettings.appStorageKey) - ?? MobileBridgeSettings.defaultMode.rawValue - return MobileBridgeSettings.mode(for: raw) != .off - } - - // MARK: - `queue`-confined - - private func handleChange(blockedCount: Int, workingCount: Int, newlyBlockedWorkspaceTitle: String?) { - if let newlyBlockedWorkspaceTitle { - trackedBlockedTitle = newlyBlockedWorkspaceTitle - } - if blockedCount == 0 { - trackedBlockedTitle = nil - } - - let summary = Summary( - blockedCount: blockedCount, - workingCount: workingCount, - mostRecentBlockedWorkspaceTitle: trackedBlockedTitle - ) - guard summary != lastWrittenSummary, summary != pendingSummary else { return } - pendingSummary = summary - scheduleCoalescedWriteIfNeeded() - } - - private func scheduleCoalescedWriteIfNeeded() { - guard !coalesceScheduled else { return } - coalesceScheduled = true - let elapsed = Date().timeIntervalSince(lastWriteAt) - let delay = max(0, Self.minimumWriteInterval - elapsed) - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.flushPendingWrite() - } - } - - private func flushPendingWrite() { - coalesceScheduled = false - guard let summary = pendingSummary, summary != lastWrittenSummary else { - pendingSummary = nil - return - } - pendingSummary = nil - lastWriteAt = Date() - checkAccountStatusAndSave(summary) - } - - private func checkAccountStatusAndSave(_ summary: Summary) { - container.accountStatus { [weak self] status, error in - guard let self else { return } - self.queue.async { - guard status == .available, error == nil else { - if !self.didLogAccountUnavailable { - self.didLogAccountUnavailable = true -#if DEBUG - dlog( - "mobileBridge.push.accountUnavailable status=\(status.rawValue) " + - "error=\(String(describing: error))" - ) -#endif - } - return - } - self.didLogAccountUnavailable = false - self.performSave(summary) - } - } - } - - /// Runs on `queue`. Always constructs a fresh `CKRecord` (never fetches first) and saves - /// with `.changedKeys` -- correct for this record's single-writer-per-account semantics - /// (only this Mac, under this iCloud account, ever writes `agent-status-summary`), and - /// avoids the `.ifServerRecordUnchanged` default's change-tag conflict since we never hold - /// a server-issued tag. - private func performSave(_ summary: Summary) { - let recordID = CKRecord.ID(recordName: Self.recordName) - let record = CKRecord(recordType: Self.recordType, recordID: recordID) - record["blockedCount"] = summary.blockedCount as CKRecordValue - record["workingCount"] = summary.workingCount as CKRecordValue - if let title = summary.mostRecentBlockedWorkspaceTitle { - record["mostRecentBlockedWorkspaceTitle"] = title as CKRecordValue - } else { - // Explicit nil clears the field server-side (and counts as a "changed key") -- - // omitting the key entirely would leave a stale title from a previous block. - record["mostRecentBlockedWorkspaceTitle"] = nil - } - - let operation = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil) - operation.savePolicy = .changedKeys - operation.qualityOfService = .utility - operation.modifyRecordsResultBlock = { [weak self] result in - guard let self else { return } - self.queue.async { - switch result { - case .success: - self.lastWrittenSummary = summary -#if DEBUG - dlog( - "mobileBridge.push.wrote blocked=\(summary.blockedCount) " + - "working=\(summary.workingCount)" - ) -#endif - case let .failure(error): - NSLog("MobileBridgePush: CloudKit write failed: %@", "\(error)") - } - } - } - container.privateCloudDatabase.add(operation) - } -} diff --git a/Sources/MobileBridge/MobileBridgeSession.swift b/Sources/MobileBridge/MobileBridgeSession.swift deleted file mode 100644 index a6f10730..00000000 --- a/Sources/MobileBridge/MobileBridgeSession.swift +++ /dev/null @@ -1,403 +0,0 @@ -import Darwin -import Foundation - -protocol MobileBridgeRelayLineReading: Sendable { - func nextLine() async throws -> Data? -} - -protocol MobileBridgeRelayFrameWriting: Sendable { - func writeLine(_ data: Data) async throws -} - -protocol MobileBridgeRelayLocalPiping: Sendable { - func nextLine() async throws -> Data? - func send(_ data: Data) async throws - func shutdownLocalEnd() -} - -extension MobileBridgeStreamLineReader: MobileBridgeRelayLineReading {} -extension MobileBridgeFrameWriter: MobileBridgeRelayFrameWriting {} - -final class MobileBridgeCloseOnce: @unchecked Sendable { - private let lock = NSLock() - private var action: (@Sendable () -> Void)? - - init(_ action: @escaping @Sendable () -> Void) { - self.action = action - } - - func close() { - let action = lock.withLock { - let action = self.action - self.action = nil - return action - } - action?() - } -} - -/// One phone connection's admission and relay to Programa's terminal -/// control dispatch. Ported from -/// `tools/mobile-spike/Sources/iroh-spike/Bridge.swift`'s `admit`/`relay`/ -/// `forwardPhoneLine`, with one substitution: instead of opening an -/// `AF_UNIX` connection to Programa's control socket (`UnixSocketPipe`), -/// each admitted phone gets a `socketpair` wired directly in-process to -/// `TerminalController.handleClient` (see `MobileBridgeLocalPipe` below). -/// -/// Note: `TerminalController.handleClient`'s read loop only runs while -/// `TerminalController` considers itself started (i.e. Socket Control Mode -/// is not Off, which is the default). The mobile bridge does not start or -/// otherwise touch the real Unix socket listener -- see -/// `MobileBridgeListener.start(tabManager:)`, which only assigns -/// `TerminalController.shared.tabManager`. In the out-of-the-box -/// configuration (Socket Control Mode defaults to `cmuxOnly`) this is -/// already satisfied; a Mac with Socket Control Mode explicitly set to Off -/// will admit and relay-connect phones, but `handleClient` will return -/// immediately without processing any commands. -enum MobileBridgeSession { - enum AdmissionOutcome: Sendable { - case trusted - case paired(label: String) - } - - /// Admission order: trusted devices are admitted outright; otherwise, - /// if a pairing window is open and unexpired, the first line is read - /// and checked as a `{"pair":"<token>"}` frame; otherwise the - /// connection is rejected as not paired. Pairing only proves the token; - /// the listener commits trust and registration as one transaction. - static func admit( - idString: String, - reader: MobileBridgeStreamLineReader, - writer: MobileBridgeFrameWriter, - pairingWindow: MobileBridgePairingWindow? - ) async throws -> AdmissionOutcome? { - if await MobileBridgeTrustedDeviceStore.shared.isTrusted(idString) { - return .trusted - } - - if let pairingWindow, pairingWindow.isOpen { - guard let firstLine = try await reader.nextLine() else { - return nil - } - guard - let object = try? JSONSerialization.jsonObject(with: firstLine) as? [String: Any], - let presentedToken = object["pair"] as? String - else { - try? await writer.writeLine(errorFrame(id: nil, code: "pairing_failed")) - return nil - } - - let matched = pairingWindow.attemptConsume(Data(presentedToken.utf8)) - if matched { - // The phone may send a human-readable name alongside the - // token so the device list reads "Franco's iPhone" rather - // than 64 hex characters. Optional on the wire. Trim and - // bound it: this string comes from a remote peer and gets - // persisted and displayed. - let label = (object["label"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .prefix(64) - .description - let resolvedLabel = label.flatMap { $0.isEmpty ? nil : $0 } ?? "paired-device" - return .paired(label: resolvedLabel) - } else { - try? await writer.writeLine(errorFrame(id: nil, code: "pairing_failed")) - return nil - } - } - - try? await writer.writeLine(errorFrame(id: nil, code: "not_paired")) - return nil - } - - /// Creates a connected `socketpair`, hands one end to - /// `TerminalController.handleClient` on a dedicated thread (the same - /// per-connection thread model the real Unix socket listener uses), and - /// pumps bytes on the other end for this connection's lifetime. - /// Phone-originated lines are checked against - /// `MobileBridgeMethodAllowList` before being forwarded; Programa's - /// replies and pushed subscription events are forwarded to the phone - /// unfiltered. Ends (and cancels the other direction) as soon as either - /// side closes. - static func relay( - reader: MobileBridgeStreamLineReader, - writer: MobileBridgeFrameWriter, - idString: String, - closeRemote: @escaping @Sendable () -> Void - ) async { - let remoteClose = MobileBridgeCloseOnce(closeRemote) - defer { remoteClose.close() } - - // Greet every admitted phone, pairing and trusted reconnect alike, so a - // renamed Mac corrects itself on the next connect instead of staying - // stale until re-pair. Shaped as an event frame ("event" key, no "id") - // so it rides the client's existing demux for unsolicited frames. - let hello: [String: Any] = [ - "event": "bridge_hello", - "mac_name": mobileBridgeLocalMacName(), - ] - if let helloData = try? JSONSerialization.data(withJSONObject: hello) { - try? await writer.writeLine(helloData) - } - - guard let pipe = MobileBridgeLocalPipe.make() else { - NSLog("MobileBridge: failed to create socketpair for %@", idString) - return - } - defer { pipe.closeLocalEnd() } - - // `handleClient` owns and closes `remoteFD` itself (its own - // `defer { close(socket) }`); this relay never touches it again - // after handing it off. - let remoteFD = pipe.remoteFD - Thread.detachNewThread { - TerminalController.shared.handleClient(remoteFD, peerPid: getpid(), source: .mobileBridge) - } - - await pump( - reader: reader, - writer: writer, - pipe: pipe, - closeRemote: { remoteClose.close() }, - idString: idString - ) - } - - static func pump( - reader: any MobileBridgeRelayLineReading, - writer: any MobileBridgeRelayFrameWriting, - pipe: any MobileBridgeRelayLocalPiping, - closeRemote: @escaping @Sendable () -> Void, - idString: String = "unknown" - ) async { - let shutdown = MobileBridgeCloseOnce { - closeRemote() - pipe.shutdownLocalEnd() - } - - await withTaskCancellationHandler { - await withTaskGroup(of: Void.self) { group in - group.addTask { - do { - while let line = try await reader.nextLine() { - await forwardPhoneLine( - line, - pipe: pipe, - writer: writer, - idString: idString - ) - } - } catch { - NSLog("MobileBridge: phone read error for %@: %@", idString, "\(error)") - } - } - group.addTask { - do { - while let line = try await pipe.nextLine() { - try await writer.writeLine(line) - } - } catch { - NSLog("MobileBridge: local read error for %@: %@", idString, "\(error)") - } - } - await group.next() - shutdown.close() - group.cancelAll() - } - } onCancel: { - shutdown.close() - } - } - - private static func forwardPhoneLine( - _ line: Data, - pipe: any MobileBridgeRelayLocalPiping, - writer: any MobileBridgeRelayFrameWriting, - idString: String - ) async { - guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { - try? await writer.writeLine(errorFrame(id: nil, code: "invalid_json")) - return - } - let requestId = object["id"] - guard let method = object["method"] as? String else { - try? await writer.writeLine(errorFrame(id: requestId, code: "invalid_json")) - return - } - guard MobileBridgeMethodAllowList.isAllowed(method) else { - try? await writer.writeLine(errorFrame( - id: requestId, - code: "forbidden", - message: "method not permitted over mobile bridge" - )) - return - } - do { - try await pipe.send(line + Data("\n".utf8)) - } catch { - NSLog("MobileBridge: forwarding to terminal control failed for %@: %@", idString, "\(error)") - } - } - - private static func errorFrame(id: Any?, code: String, message: String? = nil) -> Data { - var errorObject: [String: Any] = ["code": code] - if let message { errorObject["message"] = message } - var frame: [String: Any] = ["ok": false, "error": errorObject] - if let id, !(id is NSNull) { - frame["id"] = id - } - if let data = try? JSONSerialization.data(withJSONObject: frame) { - return data - } - return Data(#"{"ok":false,"error":{"code":"\#(code)"}}"#.utf8) - } -} - -/// A connected `AF_UNIX`/`SOCK_STREAM` pair created via `socketpair(2)` -- -/// no filesystem path, no listener, no auth boundary of its own (the -/// phone's authorization already happened upstream: the iroh admission -/// handshake plus `MobileBridgeMethodAllowList`). One end (`remoteFD`) is -/// handed to `TerminalController.handleClient`; the other (`localFD`, -/// private) is driven by this relay's reader/writer tasks. -/// -/// Mirrors `tools/mobile-spike/Sources/iroh-spike/UnixSocketPipe.swift`'s -/// blocking read/write bridged onto a background dispatch queue, since -/// `handleClient` and this pipe's local end both perform blocking -/// `read`/`write` syscalls that must never run on Swift Concurrency's -/// cooperative thread pool. -final class MobileBridgeLocalPipe: MobileBridgeRelayLocalPiping, @unchecked Sendable { - let remoteFD: Int32 - private let localFD: Int32 - private var buffer = Data() - private let closeLock = NSLock() - private var localShutdown = false - private var localClosed = false - - private init(localFD: Int32, remoteFD: Int32) { - self.localFD = localFD - self.remoteFD = remoteFD - } - - static func make() -> MobileBridgeLocalPipe? { - var fds: [Int32] = [0, 0] - let result = fds.withUnsafeMutableBufferPointer { buffer -> Int32 in - socketpair(AF_UNIX, SOCK_STREAM, 0, buffer.baseAddress) - } - guard result == 0 else { return nil } - return MobileBridgeLocalPipe(localFD: fds[0], remoteFD: fds[1]) - } - - /// Closes this relay's own end of the pair. `TerminalController.handleClient` - /// closes `remoteFD` itself via its own `defer`, so this must never - /// touch `remoteFD`. - func closeLocalEnd() { - closeLock.withLock { - guard !localClosed else { return } - localClosed = true - Darwin.close(localFD) - } - } - - /// Wakes both blocking local-end syscalls without releasing the file - /// descriptor. The pump closes it only after both child tasks have joined, - /// so a concurrent teardown cannot target a reused descriptor. - func shutdownLocalEnd() { - closeLock.withLock { - guard !localClosed, !localShutdown else { return } - localShutdown = true - Darwin.shutdown(localFD, SHUT_RDWR) - } - } - - private func readLineBlocking() throws -> Data? { - while true { - if let newlineIndex = buffer.firstIndex(of: 0x0A) { - let line = Data(buffer[buffer.startIndex ..< newlineIndex]) - buffer.removeSubrange(buffer.startIndex ... newlineIndex) - return line - } - var chunk = [UInt8](repeating: 0, count: 65536) - let bytesRead = chunk.withUnsafeMutableBytes { rawPointer in - Darwin.read(localFD, rawPointer.baseAddress, rawPointer.count) - } - if bytesRead < 0 { - if errno == EINTR { continue } - throw MobileBridgePipeError(message: "read from local pipe failed: \(String(cString: strerror(errno)))") - } - if bytesRead == 0 { - if !buffer.isEmpty { - let remaining = buffer - buffer.removeAll() - return remaining - } - return nil - } - buffer.append(contentsOf: chunk[0 ..< bytesRead]) - } - } - - private func writeAllBlocking(_ data: Data) throws { - var offset = 0 - try data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in - guard let base = raw.baseAddress else { return } - while offset < data.count { - let written = Darwin.write(localFD, base + offset, data.count - offset) - if written < 0 { - if errno == EINTR { continue } - throw MobileBridgePipeError(message: "write to local pipe failed: \(String(cString: strerror(errno)))") - } - if written == 0 { - throw MobileBridgePipeError(message: "local pipe closed during write") - } - offset += written - } - } - } - - /// Reads the next newline-delimited line. Returns `nil` at end of - /// stream. Blocking -- always awaited, never called directly, from - /// async contexts. - func nextLine() async throws -> Data? { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - continuation.resume(returning: try self.readLineBlocking()) - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// Writes `data` in full. Blocking -- always awaited, never called - /// directly, from async contexts. - func send(_ data: Data) async throws { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - try self.writeAllBlocking(data) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } -} - -struct MobileBridgePipeError: Error, CustomStringConvertible { - let message: String - var description: String { message } -} - - -/// The Mac's user-facing computer name ("Franco's MacBook Pro") -- what System -/// Settings shows and what a person recognises on their phone. Falls back to -/// the network hostname, then a generic label. -func mobileBridgeLocalMacName() -> String { - if let localized = Host.current().localizedName, !localized.isEmpty { - return localized - } - let hostName = ProcessInfo.processInfo.hostName - return hostName.isEmpty ? "Mac" : hostName -} diff --git a/Sources/MobileBridge/MobileBridgeSettings.swift b/Sources/MobileBridge/MobileBridgeSettings.swift deleted file mode 100644 index b4b4ac62..00000000 --- a/Sources/MobileBridge/MobileBridgeSettings.swift +++ /dev/null @@ -1,235 +0,0 @@ -import Foundation - -/// Access mode for Programa's mobile companion bridge (M1): whether the -/// in-process iroh listener that lets a paired iPhone reach this Mac's -/// terminal control dispatch is running at all, and if so, whether only -/// devices that completed the one-time pairing handshake may connect. -/// Mirrors the shape of `SocketControlMode` (see `SocketControlSettings.swift`) -/// but is a wholly separate on/off switch from Programa's local Unix -/// control socket -- turning this on never changes the local socket's -/// access mode. -enum MobileBridgeMode: String, CaseIterable, Identifiable { - case off - case pairedDevicesOnly - - var id: String { rawValue } - - static var uiCases: [MobileBridgeMode] { [.off, .pairedDevicesOnly] } - - var displayName: String { - switch self { - case .off: - return String(localized: "settings.phone.mode.off", defaultValue: "Off") - case .pairedDevicesOnly: - return String(localized: "settings.phone.mode.pairedDevicesOnly", defaultValue: "Paired Devices Only") - } - } -} - -struct MobileBridgeSettings { - static let appStorageKey = "mobileBridgeMode" - - static var defaultMode: MobileBridgeMode { .off } - - static func mode(for raw: String) -> MobileBridgeMode { - MobileBridgeMode(rawValue: raw) ?? defaultMode - } -} - -/// Shared storage directory for mobile-bridge identity/trust state, under -/// the same `~/Library/Application Support/programa` directory Programa's -/// control socket uses (see `SocketControlSettings`'s `stableSocketDirectoryURL`) -/// -- never the app bundle, so identity and pairings survive reinstalls and -/// version updates. -enum MobileBridgeHome { - static func directory(fileManager: FileManager = .default) -> URL { - let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true) - return base.appendingPathComponent("programa", isDirectory: true) - } -} - -/// A phone identity that has completed the pairing token flow and may -/// connect to the mobile bridge without re-presenting a token. Ported from -/// `tools/mobile-spike/Sources/iroh-spike/TrustedDeviceStore.swift`. -struct MobileBridgeTrustedDevice: Codable, Equatable, Identifiable { - let endpointId: String - let label: String - let pairedAt: Date - - var id: String { endpointId } -} - -/// Persisted allow-list of previously paired phone identities, so pairing is -/// a one-time step per device. Same storage convention as the CLI spike: -/// plaintext JSON under `MobileBridgeHome`, owner-only (0600) permissions. -/// Unlike the spike, devices can also be removed (Settings "Remove" action). -actor MobileBridgeTrustedDeviceStore { - typealias Persistence = @Sendable (Data, URL) throws -> Void - - struct RevocationResult: Sendable { - let closeActions: [MobileBridgeConnectionRegistry.CloseAction] - let persistenceFailure: String? - } - - static let shared = MobileBridgeTrustedDeviceStore() - - private var devices: [MobileBridgeTrustedDevice] = [] - private var didLoad = false - private let fileURL: URL - private let persistence: Persistence - - init( - fileURL: URL = MobileBridgeHome.directory().appendingPathComponent("mobile-bridge-trusted-devices.json"), - persistence: @escaping Persistence = { data, fileURL in - try MobileBridgeTrustedDeviceStore.persistToDisk(data, fileURL) - } - ) { - self.fileURL = fileURL - self.persistence = persistence - } - - private func loadIfNeeded() { - guard !didLoad else { return } - didLoad = true - guard - let data = try? Data(contentsOf: fileURL), - let decoded = try? JSONDecoder().decode([MobileBridgeTrustedDevice].self, from: data) - else { return } - devices = decoded - } - - func isTrusted(_ endpointId: String) -> Bool { - loadIfNeeded() - return devices.contains { $0.endpointId == endpointId } - } - - func registerPairedIfCurrent( - endpointId: String, - label: String, - registry: MobileBridgeConnectionRegistry, - connectionID: ObjectIdentifier, - ticket: MobileBridgeConnectionRegistry.AdmissionTicket, - close: @escaping MobileBridgeConnectionRegistry.CloseAction - ) -> MobileBridgeConnectionRegistry.RegistrationResult { - registry.registerIfCurrent( - connectionID: connectionID, - ticket: ticket, - close: close, - beforeRegister: { - self.loadIfNeeded() - guard !self.devices.contains(where: { $0.endpointId == endpointId }) else { - return true - } - - let previousDevices = self.devices - self.devices.append(MobileBridgeTrustedDevice( - endpointId: endpointId, - label: label, - pairedAt: Date() - )) - do { - try self.persist() - return true - } catch { - self.devices = previousDevices - NSLog("MobileBridge: failed to persist paired device: %@", "\(error)") - return false - } - } - ) - } - - func revokeAndClaimConnections( - endpointId: String, - registry: MobileBridgeConnectionRegistry - ) -> RevocationResult { - loadIfNeeded() - let previousDevices = devices - devices.removeAll { $0.endpointId == endpointId } - do { - try persist() - } catch { - devices = previousDevices - return RevocationResult( - closeActions: [], - persistenceFailure: String(describing: error) - ) - } - - return RevocationResult( - closeActions: registry.revoke(endpointId: endpointId), - persistenceFailure: nil - ) - } - - func allDevices() -> [MobileBridgeTrustedDevice] { - loadIfNeeded() - return devices.sorted { $0.pairedAt > $1.pairedAt } - } - - private func persist() throws { - let data = try JSONEncoder().encode(devices) - try persistence(data, fileURL) - } - - private static func persistToDisk(_ data: Data, _ fileURL: URL) throws { - let fileManager = FileManager.default - let directory = fileURL.deletingLastPathComponent() - try fileManager.createDirectory( - at: directory, - withIntermediateDirectories: true - ) - - let temporaryURL = directory.appendingPathComponent( - ".\(fileURL.lastPathComponent).\(UUID().uuidString).tmp" - ) - var committed = false - defer { - if !committed { - try? fileManager.removeItem(at: temporaryURL) - } - } - - try data.write(to: temporaryURL, options: [.atomic, .withoutOverwriting]) - try fileManager.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: temporaryURL.path - ) - - if fileManager.fileExists(atPath: fileURL.path) { - _ = try fileManager.replaceItemAt( - fileURL, - withItemAt: temporaryURL, - backupItemName: nil, - options: .usingNewMetadataOnly - ) - } else { - try fileManager.moveItem(at: temporaryURL, to: fileURL) - } - committed = true - } -} - -/// Persists a stable 32-byte Iroh secret key across app relaunches so this -/// Mac's mobile-bridge node identity survives restarts. Ported from -/// `tools/mobile-spike/Sources/iroh-spike/SecretKeyStore.swift`; stored -/// under Application Support (never in the app bundle), owner-only (0600). -enum MobileBridgeSecretKeyStore { - static func loadOrCreate(fileManager: FileManager = .default) throws -> Data { - let directory = MobileBridgeHome.directory(fileManager: fileManager) - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - let file = directory.appendingPathComponent("mobile-bridge-secret-key") - if let existing = try? Data(contentsOf: file), existing.count == 32 { - return existing - } - - let bytes = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) - try bytes.write(to: file, options: .atomic) - try? fileManager.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: file.path - ) - return bytes - } -} diff --git a/Sources/MobileBridge/MobileBridgeStreamSupport.swift b/Sources/MobileBridge/MobileBridgeStreamSupport.swift deleted file mode 100644 index 092049ae..00000000 --- a/Sources/MobileBridge/MobileBridgeStreamSupport.swift +++ /dev/null @@ -1,178 +0,0 @@ -import Foundation -import IrohLib - -enum MobileBridgeStreamLineReaderError: Error { - case frameTooLarge -} - -/// Buffers reads from a QUIC `RecvStream` and splits them on `\n`, mirroring -/// the newline-delimited JSON-RPC framing Programa's own control socket -/// uses. Ported verbatim (renamed) from -/// `tools/mobile-spike/Sources/iroh-spike/StreamFraming.swift`'s -/// `StreamLineReader` -- see that file for the full rationale. -/// -/// Not safe to call `nextLine()` concurrently from two callers -- each -/// instance is driven by exactly one reader task for its lifetime. -final class MobileBridgeStreamLineReader: @unchecked Sendable { - private static let maximumLineByteCount = 8 * 1024 * 1024 - - private let stream: RecvStream - private var buffer = Data() - private var scannedByteCount = 0 - private let chunkSize = 65_536 - - init(stream: RecvStream) { - self.stream = stream - } - - /// Returns the next line (without its trailing `\n`), or `nil` at end of - /// stream. - func nextLine() async throws -> Data? { - while true { - let searchStartIndex = buffer.index( - buffer.startIndex, - offsetBy: scannedByteCount - ) - if let newlineIndex = buffer[searchStartIndex...].firstIndex(of: 0x0A) { - guard buffer.distance(from: buffer.startIndex, to: newlineIndex) - <= Self.maximumLineByteCount - else { - throw MobileBridgeStreamLineReaderError.frameTooLarge - } - let line = Data(buffer[buffer.startIndex ..< newlineIndex]) - buffer.removeSubrange(buffer.startIndex ... newlineIndex) - scannedByteCount = 0 - return line - } - scannedByteCount = buffer.count - guard buffer.count <= Self.maximumLineByteCount else { - throw MobileBridgeStreamLineReaderError.frameTooLarge - } - - let bytesUntilOverflow = Self.maximumLineByteCount + 1 - buffer.count - let readLimit = UInt32(min(chunkSize, bytesUntilOverflow)) - let chunk = try await stream.read(sizeLimit: readLimit) - if chunk.isEmpty { - if !buffer.isEmpty { - let remaining = buffer - buffer.removeAll() - scannedByteCount = 0 - return remaining - } - scannedByteCount = 0 - return nil - } - buffer.append(chunk) - } - } -} - -/// Serializes writes to a QUIC `SendStream`. Ported verbatim (renamed) from -/// `tools/mobile-spike/Sources/iroh-spike/StreamFraming.swift`'s -/// `FrameWriter`. -actor MobileBridgeFrameWriter { - private let stream: SendStream - - init(stream: SendStream) { - self.stream = stream - } - - func writeLine(_ data: Data) async throws { - var framed = data - framed.append(0x0A) - try await stream.writeAll(buf: framed) - } -} - -enum MobileBridgeConstantTime { - /// Manual accumulate-XOR comparison (no `==`) so a mismatching pairing - /// token doesn't leak timing information via early-exit comparison. - /// Ported verbatim from - /// `tools/mobile-spike/Sources/iroh-spike/StreamFraming.swift`. - static func equal(_ lhs: Data, _ rhs: Data) -> Bool { - guard lhs.count == rhs.count else { return false } - var diff: UInt8 = 0 - for index in 0 ..< lhs.count { - diff |= lhs[lhs.startIndex + index] ^ rhs[rhs.startIndex + index] - } - return diff == 0 - } -} - -enum MobileBridgeBase64URL { - static func encode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} - -/// A single-use, time-boxed pairing invitation opened from Settings. Holds -/// the freshly generated token in memory only (never persisted) and closes -/// itself the moment a matching token is presented, so a displayed token -/// can't be replayed later to pair a second device once the intended one -/// has paired. Ported verbatim (renamed) from -/// `tools/mobile-spike/Sources/iroh-spike/PairingWindow.swift`. -final class MobileBridgePairingWindow: @unchecked Sendable { - private let lock = NSLock() - private var tokenData: Data? - private let expiresAt: ContinuousClock.Instant - - init(token: Data, duration: Duration) { - tokenData = token - expiresAt = ContinuousClock.now.advanced(by: duration) - } - - var isOpen: Bool { - lock.withLock { - tokenData != nil && ContinuousClock.now < expiresAt - } - } - - /// Compares `presented` against the window's token in constant time. On - /// match, consumes (closes) the window and returns `true`. On mismatch, - /// leaves the window open -- a mistyped attempt shouldn't lock out a - /// legitimate retry within the 5-minute window -- and returns `false`. - func attemptConsume(_ presented: Data) -> Bool { - lock.withLock { - guard let tokenData, ContinuousClock.now < expiresAt else { return false } - guard MobileBridgeConstantTime.equal(tokenData, presented) else { return false } - self.tokenData = nil - return true - } - } - - func invalidate() { - lock.withLock { - tokenData = nil - } - } -} - -/// The only JSON-RPC methods the mobile bridge will forward to Programa's -/// terminal control dispatch. Everything else -- including destructive -/// methods like `worktree.remove` and `browser.navigate` -- is rejected -/// before it ever reaches `TerminalController.handleClient`. -/// -/// This is the bridge's core security boundary: the control dispatch has no -/// notion of a restricted "mobile" client, so enforcement lives entirely -/// here. Ported verbatim from -/// `tools/mobile-spike/Sources/iroh-spike/MethodAllowList.swift` -- do not -/// widen without a matching change to the M1 plan. -enum MobileBridgeMethodAllowList { - static let allowed: Set<String> = [ - "system.ping", - "workspace.list", - "surface.list", - "subscribe", - "unsubscribe", - "agent.prompt", - "surface.send_text", - "surface.send_key", - ] - - static func isAllowed(_ method: String) -> Bool { - allowed.contains(method) - } -} diff --git a/Sources/NotificationSoundStaging.swift b/Sources/NotificationSoundStaging.swift index 11d01040..a94c427a 100644 --- a/Sources/NotificationSoundStaging.swift +++ b/Sources/NotificationSoundStaging.swift @@ -2,68 +2,18 @@ import AppKit import Foundation import UserNotifications -// MARK: - Notification Sound Staging +// MARK: - Notification Sound Settings // -// Sound-transcoding subsystem for custom notification sounds: validates and stages -// user-selected sound files into a location/format usable as a macOS UNNotificationSound -// (transcoding to a supported extension when needed via afconvert), and local playback -// preview support. Extracted from TerminalNotificationStore.swift, which owns the rest of -// notification delivery/state and is a heavy consumer of this settings surface. +// System-sound picker for notification delivery: exposes the built-in macOS sound names +// available to UNNotificationSound and simple playback for settings-UI previews. The +// custom-file staging/transcoding flow that used to live here was removed; see +// docs/removed/custom-notification-sounds.md. Extracted from TerminalNotificationStore.swift, +// which owns the rest of notification delivery/state and is a heavy consumer of this +// settings surface. enum NotificationSoundSettings { static let key = "notificationSound" static let defaultValue = "default" - static let customFileValue = "custom_file" - static let customFilePathKey = "notificationSoundCustomFilePath" - static let defaultCustomFilePath = "" - private static let stagedCustomSoundBaseName = "cmux-custom-notification-sound" - private static let customSoundPreparationQueue = DispatchQueue( - label: "com.cmuxterm.notification-sound-preparation", - qos: .utility - ) - private static let pendingCustomSoundPreparationLock = NSLock() - private static var pendingCustomSoundPreparationPaths: Set<String> = [] - private static let activePlaybackSoundsLock = NSLock() - private static var activePlaybackSounds: [ObjectIdentifier: NSSound] = [:] - private static let activePlaybackSoundDelegate = ActivePlaybackSoundDelegate() - private static let notificationSoundSupportedExtensions: Set<String> = [ - "aif", - "aiff", - "caf", - "wav", - ] - - private final class ActivePlaybackSoundDelegate: NSObject, NSSoundDelegate { - func sound(_ sound: NSSound, didFinishPlaying finishedPlaying: Bool) { - NotificationSoundSettings.releaseActivePlaybackSound(sound) - } - } - - private struct CustomSoundSourceMetadata: Codable, Equatable { - let sourcePath: String - let sourceSize: UInt64 - let sourceModificationTime: Double - let sourceFileIdentifier: UInt64? - } - enum CustomSoundPreparationIssue: Error { - case emptyPath - case missingFile(path: String) - case missingFileExtension(path: String) - case stagingFailed(path: String, details: String) - - var logMessage: String { - switch self { - case .emptyPath: - return "Notification custom sound path is empty" - case .missingFile(let path): - return "Notification custom sound file does not exist: \(path)" - case .missingFileExtension(let path): - return "Notification custom sound requires a file extension: \(path)" - case .stagingFailed(let path, let details): - return "Failed to stage custom notification sound from \(path): \(details)" - } - } - } static let customCommandKey = "notificationCustomCommand" static let defaultCustomCommand = "" @@ -83,7 +33,6 @@ enum NotificationSoundSettings { ("Sosumi", "Sosumi"), ("Submarine", "Submarine"), ("Tink", "Tink"), - ("Custom File...", customFileValue), ("None", "none"), ] @@ -94,412 +43,39 @@ enum NotificationSoundSettings { return .default case "none": return nil - case customFileValue: - guard let customSoundName = stagedCustomSoundName(defaults: defaults) else { - return nil - } - return UNNotificationSound(named: UNNotificationSoundName(rawValue: customSoundName)) default: return UNNotificationSound(named: UNNotificationSoundName(rawValue: value)) } } static func usesSystemSound(defaults: UserDefaults = .standard) -> Bool { - let value = defaults.string(forKey: key) ?? defaultValue - switch value { - case "none": - return false - case customFileValue: - return customFileURL(defaults: defaults) != nil - default: - return true - } + (defaults.string(forKey: key) ?? defaultValue) != "none" } static func isSilent(defaults: UserDefaults = .standard) -> Bool { return (defaults.string(forKey: key) ?? defaultValue) == "none" } - static func isCustomFileSelected(defaults: UserDefaults = .standard) -> Bool { - (defaults.string(forKey: key) ?? defaultValue) == customFileValue - } - - static func stagedCustomSoundName(defaults: UserDefaults = .standard) -> String? { - let rawPath = defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath - guard let normalizedPath = normalizedCustomFilePath(rawPath) else { - NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.emptyPath.logMessage)") - return nil - } - - let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) - let sourceExtension = sourceURL.pathExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard !sourceExtension.isEmpty else { - NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFileExtension(path: sourceURL.path).logMessage)") - return nil - } - - let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) - let stagedFileName = stagedCustomSoundFileName( - forSourceURL: sourceURL, - destinationExtension: destinationExtension - ) - let stagedURL = stagedSoundDirectoryURL().appendingPathComponent(stagedFileName, isDirectory: false) - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: sourceURL.path) else { - NSLog("Notification custom sound unavailable: \(CustomSoundPreparationIssue.missingFile(path: sourceURL.path).logMessage)") - return nil - } - - if fileManager.fileExists(atPath: stagedURL.path) { - if let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager), - let stagedMetadata = loadStagedSourceMetadata(for: stagedURL), - stagedMetadata == sourceMetadata { - return stagedFileName - } - } - - if destinationExtension == sourceExtension { - switch prepareCustomFileForNotifications(path: normalizedPath) { - case .success(let preparedName): - return preparedName - case .failure(let issue): - NSLog("Notification custom sound unavailable: \(issue.logMessage)") - return nil - } - } - - queueCustomSoundPreparation(path: normalizedPath) - NSLog("Notification custom sound not ready yet, staging in background: \(sourceURL.path)") - return nil - } - - static func prepareCustomFileForNotifications(path: String) -> Result<String, CustomSoundPreparationIssue> { - guard let normalizedPath = normalizedCustomFilePath(path) else { - return .failure(.emptyPath) - } - let sourceURL = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) - return prepareCustomSound(from: sourceURL) - } - - private static func prepareCustomSound(from sourceURL: URL) -> Result<String, CustomSoundPreparationIssue> { - let sourcePath = sourceURL.path - let fileManager = FileManager.default - guard fileManager.fileExists(atPath: sourcePath) else { - return .failure(.missingFile(path: sourcePath)) - } - let sourceExtension = sourceURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - guard !sourceExtension.isEmpty else { - return .failure(.missingFileExtension(path: sourcePath)) - } - let destinationExtension = stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) - - let destinationDirectory = stagedSoundDirectoryURL() - let destinationFileName = stagedCustomSoundFileName( - forSourceURL: sourceURL, - destinationExtension: destinationExtension - ) - let destinationURL = destinationDirectory.appendingPathComponent(destinationFileName, isDirectory: false) - let sourceMetadata = currentSourceMetadata(for: sourceURL, fileManager: fileManager) - - do { - try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) - if fileManager.fileExists(atPath: destinationURL.path) { - let stagedMetadata = loadStagedSourceMetadata(for: destinationURL) - if stagedMetadata != sourceMetadata { - try? fileManager.removeItem(at: destinationURL) - } - } - if destinationExtension == sourceExtension.lowercased() { - try copyStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) - } else { - try transcodeStagedSoundIfNeeded(from: sourceURL, to: destinationURL, fileManager: fileManager) - } - if let sourceMetadata { - try saveStagedSourceMetadata(sourceMetadata, for: destinationURL) - } - try cleanupStaleStagedSoundFiles( - in: destinationDirectory, - keeping: destinationFileName, - preservingSourceURL: sourceURL, - fileManager: fileManager - ) - return .success(destinationFileName) - } catch { - return .failure(.stagingFailed(path: sourcePath, details: error.localizedDescription)) - } - } - - static func customFileURL(defaults: UserDefaults = .standard) -> URL? { - guard let path = normalizedCustomFilePath(defaults.string(forKey: customFilePathKey) ?? defaultCustomFilePath) else { - return nil - } - return URL(fileURLWithPath: (path as NSString).expandingTildeInPath) - } - - static func playCustomFileSound(defaults: UserDefaults = .standard) { - guard let url = customFileURL(defaults: defaults) else { return } - playSoundFile(at: url) - } - - static func playCustomFileSound(path: String) { - guard let normalizedPath = normalizedCustomFilePath(path) else { return } - let url = URL(fileURLWithPath: (normalizedPath as NSString).expandingTildeInPath) - playSoundFile(at: url) - } - static func playSelectedSound(defaults: UserDefaults = .standard) { let value = defaults.string(forKey: key) ?? defaultValue - playSound(value: value, defaults: defaults) + playSound(value: value) } static func previewSound(value: String, defaults: UserDefaults = .standard) { - playSound(value: value, defaults: defaults) + playSound(value: value) } - private static func playSound(value: String, defaults: UserDefaults) { + private static func playSound(value: String) { switch value { case "default": NSSound.beep() case "none": break - case customFileValue: - playCustomFileSound(defaults: defaults) default: NSSound(named: NSSound.Name(value))?.play() } } - static func stagedCustomSoundFileExtension(forSourceExtension sourceExtension: String) -> String { - let normalized = sourceExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard !normalized.isEmpty else { return "caf" } - if notificationSoundSupportedExtensions.contains(normalized) { - return normalized - } - return "caf" - } - - static func stagedCustomSoundFileName(forSourceURL sourceURL: URL, destinationExtension: String) -> String { - let normalizedExtension = destinationExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let ext = normalizedExtension.isEmpty ? "caf" : normalizedExtension - let signature = stagedCustomSoundSourceSignature(for: sourceURL) - return "\(stagedCustomSoundBaseName)-\(signature).\(ext)" - } - - private static func normalizedCustomFilePath(_ rawPath: String) -> String? { - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - - private static func stagedSoundDirectoryURL() -> URL { - URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - } - - private static func queueCustomSoundPreparation(path: String) { - let expandedPath = (path as NSString).expandingTildeInPath - pendingCustomSoundPreparationLock.lock() - if pendingCustomSoundPreparationPaths.contains(expandedPath) { - pendingCustomSoundPreparationLock.unlock() - return - } - pendingCustomSoundPreparationPaths.insert(expandedPath) - pendingCustomSoundPreparationLock.unlock() - - customSoundPreparationQueue.async { - defer { - pendingCustomSoundPreparationLock.lock() - pendingCustomSoundPreparationPaths.remove(expandedPath) - pendingCustomSoundPreparationLock.unlock() - } - _ = prepareCustomFileForNotifications(path: expandedPath) - } - } - - private static func playSoundFile(at url: URL) { - DispatchQueue.main.async { - guard let sound = NSSound(contentsOf: url, byReference: false) else { - NSLog("Notification custom sound failed to load from path: \(url.path)") - return - } - retainActivePlaybackSound(sound) - sound.delegate = activePlaybackSoundDelegate - if !sound.play() { - releaseActivePlaybackSound(sound) - } - } - } - - private static func retainActivePlaybackSound(_ sound: NSSound) { - activePlaybackSoundsLock.lock() - activePlaybackSounds[ObjectIdentifier(sound)] = sound - activePlaybackSoundsLock.unlock() - } - - private static func releaseActivePlaybackSound(_ sound: NSSound) { - activePlaybackSoundsLock.lock() - activePlaybackSounds.removeValue(forKey: ObjectIdentifier(sound)) - activePlaybackSoundsLock.unlock() - } - - private static func cleanupStaleStagedSoundFiles( - in directoryURL: URL, - keeping fileName: String, - preservingSourceURL: URL, - fileManager: FileManager - ) throws { - let legacyPrefix = "\(stagedCustomSoundBaseName)." - let hashedPrefix = "\(stagedCustomSoundBaseName)-" - let normalizedSource = preservingSourceURL.standardizedFileURL - let keptStagedURL = directoryURL.appendingPathComponent(fileName, isDirectory: false) - let keptMetadataFileName = stagedSourceMetadataURL(for: keptStagedURL).lastPathComponent - for fileNameCandidate in try fileManager.contentsOfDirectory(atPath: directoryURL.path) { - let isManagedName = fileNameCandidate.hasPrefix(legacyPrefix) || fileNameCandidate.hasPrefix(hashedPrefix) - let isKeptManagedFile = fileNameCandidate == fileName || fileNameCandidate == keptMetadataFileName - guard isManagedName, !isKeptManagedFile else { continue } - let staleURL = directoryURL.appendingPathComponent(fileNameCandidate, isDirectory: false) - if staleURL.standardizedFileURL == normalizedSource { - continue - } - try? fileManager.removeItem(at: staleURL) - try? fileManager.removeItem(at: stagedSourceMetadataURL(for: staleURL)) - } - } - - private static func copyStagedSoundIfNeeded( - from sourceURL: URL, - to destinationURL: URL, - fileManager: FileManager - ) throws { - let normalizedSource = sourceURL.standardizedFileURL - let normalizedDestination = destinationURL.standardizedFileURL - guard normalizedSource != normalizedDestination else { return } - - if fileManager.fileExists(atPath: normalizedDestination.path) { - let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) - let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) - let sourceSize = sourceAttributes[.size] as? NSNumber - let destinationSize = destinationAttributes[.size] as? NSNumber - let sourceDate = sourceAttributes[.modificationDate] as? Date - let destinationDate = destinationAttributes[.modificationDate] as? Date - if sourceSize == destinationSize && sourceDate == destinationDate { - return - } - try fileManager.removeItem(at: normalizedDestination) - } - - try fileManager.copyItem(at: normalizedSource, to: normalizedDestination) - } - - private static func transcodeStagedSoundIfNeeded( - from sourceURL: URL, - to destinationURL: URL, - fileManager: FileManager - ) throws { - let normalizedSource = sourceURL.standardizedFileURL - let normalizedDestination = destinationURL.standardizedFileURL - guard normalizedSource != normalizedDestination else { return } - - if fileManager.fileExists(atPath: normalizedDestination.path) { - let sourceAttributes = try fileManager.attributesOfItem(atPath: normalizedSource.path) - let destinationAttributes = try fileManager.attributesOfItem(atPath: normalizedDestination.path) - let sourceDate = sourceAttributes[.modificationDate] as? Date - let destinationDate = destinationAttributes[.modificationDate] as? Date - if let sourceDate, let destinationDate, destinationDate >= sourceDate { - return - } - try fileManager.removeItem(at: normalizedDestination) - } - - let outputPipe = Pipe() - let errorPipe = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") - process.arguments = [ - "-f", "caff", - "-d", "LEI16", - normalizedSource.path, - normalizedDestination.path, - ] - process.standardOutput = outputPipe - process.standardError = errorPipe - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorOutput = String(data: errorData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if fileManager.fileExists(atPath: normalizedDestination.path) { - try? fileManager.removeItem(at: normalizedDestination) - } - let description: String - if let errorOutput, !errorOutput.isEmpty { - description = errorOutput - } else { - description = "afconvert failed with exit code \(process.terminationStatus)" - } - throw NSError( - domain: "NotificationSoundSettings", - code: Int(process.terminationStatus), - userInfo: [ - NSLocalizedDescriptionKey: description, - ] - ) - } - } - - private static func stagedCustomSoundSourceSignature(for sourceURL: URL) -> String { - let normalizedPath = sourceURL.standardizedFileURL.path - var hash: UInt64 = 0xcbf29ce484222325 - for byte in normalizedPath.utf8 { - hash ^= UInt64(byte) - hash &*= 0x100000001b3 - } - return String(format: "%016llx", hash) - } - - private static func stagedSourceMetadataURL(for stagedURL: URL) -> URL { - stagedURL.appendingPathExtension("source-metadata") - } - - private static func currentSourceMetadata(for sourceURL: URL, fileManager: FileManager) -> CustomSoundSourceMetadata? { - guard let attributes = try? fileManager.attributesOfItem(atPath: sourceURL.path) else { - return nil - } - guard let sourceSizeNumber = attributes[.size] as? NSNumber else { - return nil - } - let sourceDate = (attributes[.modificationDate] as? Date) ?? .distantPast - let fileIdentifier = (attributes[.systemFileNumber] as? NSNumber)?.uint64Value - return CustomSoundSourceMetadata( - sourcePath: sourceURL.standardizedFileURL.path, - sourceSize: sourceSizeNumber.uint64Value, - sourceModificationTime: sourceDate.timeIntervalSinceReferenceDate, - sourceFileIdentifier: fileIdentifier - ) - } - - private static func loadStagedSourceMetadata(for stagedURL: URL) -> CustomSoundSourceMetadata? { - let metadataURL = stagedSourceMetadataURL(for: stagedURL) - guard let data = try? Data(contentsOf: metadataURL) else { - return nil - } - return try? JSONDecoder().decode(CustomSoundSourceMetadata.self, from: data) - } - - private static func saveStagedSourceMetadata(_ metadata: CustomSoundSourceMetadata, for stagedURL: URL) throws { - let metadataURL = stagedSourceMetadataURL(for: stagedURL) - let data = try JSONEncoder().encode(metadata) - try data.write(to: metadataURL, options: .atomic) - } - private static let customCommandQueue = DispatchQueue( label: "com.cmuxterm.notification-custom-command", qos: .utility diff --git a/Sources/Panels/BrowserAvailability.swift b/Sources/Panels/BrowserAvailability.swift new file mode 100644 index 00000000..271049a3 --- /dev/null +++ b/Sources/Panels/BrowserAvailability.swift @@ -0,0 +1,289 @@ +// Which browsers exist on this machine: backs PROGRAMA_DEFAULT_BROWSER / +// PROGRAMA_DEFAULT_BROWSER_BUNDLE_ID (TerminalSurface.swift) and the +// app.browsers socket command (TerminalController+System.swift). + +import Foundation +import AppKit + +struct BrowserAvailabilityDescriptor { + let shortKey: String + let displayName: String + let bundleIdentifiers: [String] + let appNames: [String] +} + +enum BrowserAvailability { + /// Bundle id -> short key used by both `PROGRAMA_DEFAULT_BROWSER` and the + /// `key` field of `app.browsers`. Falls back to the raw bundle id for + /// anything not listed here. + static let shortKeysByBundleIdentifier: [String: String] = [ + "com.apple.Safari": "safari", + "com.google.Chrome": "chrome", + "org.mozilla.firefox": "firefox", + "company.thebrowser.Browser": "arc", + "company.thebrowser.arc": "arc", + "com.brave.Browser": "brave", + "com.microsoft.edgemac": "edge", + "com.microsoft.Edge": "edge", + "app.zen-browser.zen": "zen", + "app.zen-browser.Zen": "zen", + "com.vivaldi.Vivaldi": "vivaldi", + "com.operasoftware.Opera": "opera", + "com.operasoftware.OperaGX": "opera-gx", + "com.kagi.kagimacOS": "orion", + "com.kagi.kagimacos": "orion", + "com.kagi.orion": "orion", + "company.thebrowser.Dia": "dia", + "company.thebrowser.dia": "dia", + "ai.perplexity.comet": "comet", + "one.ablaze.floorp": "floorp", + "net.waterfox.waterfox": "waterfox", + "com.feralcat.sigmaos": "sigmaos", + "com.meetsidekick.Sidekick": "sidekick", + "com.pushplaylabs.sidekick": "sidekick", + "net.imput.helium": "helium", + "com.jadenGeller.Helium": "helium", + "com.jaden.geller.helium": "helium", + "com.atlas.browser": "atlas", + "org.ladybird.Browser": "ladybird", + "org.serenityos.ladybird": "ladybird", + "org.chromium.Chromium": "chromium", + "org.chromium.ungoogled": "ungoogled-chromium", + "at.studio.AsideBrowser": "aside", + ] + + static func shortKey(forBundleIdentifier bundleIdentifier: String) -> String { + shortKeysByBundleIdentifier[bundleIdentifier] ?? bundleIdentifier + } + + static let knownBrowsers: [BrowserAvailabilityDescriptor] = [ + BrowserAvailabilityDescriptor( + shortKey: "safari", + displayName: "Safari", + bundleIdentifiers: ["com.apple.Safari"], + appNames: ["Safari.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "chrome", + displayName: "Google Chrome", + bundleIdentifiers: ["com.google.Chrome"], + appNames: ["Google Chrome.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "firefox", + displayName: "Firefox", + bundleIdentifiers: ["org.mozilla.firefox"], + appNames: ["Firefox.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "arc", + displayName: "Arc", + bundleIdentifiers: ["company.thebrowser.Browser", "company.thebrowser.arc"], + appNames: ["Arc.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "brave", + displayName: "Brave", + bundleIdentifiers: ["com.brave.Browser"], + appNames: ["Brave Browser.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "edge", + displayName: "Microsoft Edge", + bundleIdentifiers: ["com.microsoft.edgemac", "com.microsoft.Edge"], + appNames: ["Microsoft Edge.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "zen", + displayName: "Zen Browser", + bundleIdentifiers: ["app.zen-browser.zen", "app.zen-browser.Zen"], + appNames: ["Zen Browser.app", "Zen.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "vivaldi", + displayName: "Vivaldi", + bundleIdentifiers: ["com.vivaldi.Vivaldi"], + appNames: ["Vivaldi.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "opera", + displayName: "Opera", + bundleIdentifiers: ["com.operasoftware.Opera"], + appNames: ["Opera.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "opera-gx", + displayName: "Opera GX", + bundleIdentifiers: ["com.operasoftware.OperaGX"], + appNames: ["Opera GX.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "orion", + displayName: "Orion", + bundleIdentifiers: ["com.kagi.kagimacOS", "com.kagi.kagimacos", "com.kagi.orion"], + appNames: ["Orion.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "dia", + displayName: "Dia", + bundleIdentifiers: ["company.thebrowser.Dia", "company.thebrowser.dia"], + appNames: ["Dia.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "comet", + displayName: "Perplexity Comet", + bundleIdentifiers: ["ai.perplexity.comet"], + appNames: ["Perplexity Comet.app", "Comet.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "floorp", + displayName: "Floorp", + bundleIdentifiers: ["one.ablaze.floorp"], + appNames: ["Floorp.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "waterfox", + displayName: "Waterfox", + bundleIdentifiers: ["net.waterfox.waterfox"], + appNames: ["Waterfox.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "sigmaos", + displayName: "SigmaOS", + bundleIdentifiers: ["com.feralcat.sigmaos"], + appNames: ["SigmaOS.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "sidekick", + displayName: "Sidekick", + bundleIdentifiers: ["com.meetsidekick.Sidekick", "com.pushplaylabs.sidekick"], + appNames: ["Sidekick.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "helium", + displayName: "Helium", + bundleIdentifiers: ["net.imput.helium", "com.jadenGeller.Helium", "com.jaden.geller.helium"], + appNames: ["Helium.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "atlas", + displayName: "Atlas", + bundleIdentifiers: ["com.atlas.browser"], + appNames: ["Atlas.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "ladybird", + displayName: "Ladybird", + bundleIdentifiers: ["org.ladybird.Browser", "org.serenityos.ladybird"], + appNames: ["Ladybird.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "chromium", + displayName: "Chromium", + bundleIdentifiers: ["org.chromium.Chromium"], + appNames: ["Chromium.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "ungoogled-chromium", + displayName: "Ungoogled Chromium", + bundleIdentifiers: ["org.chromium.ungoogled"], + appNames: ["Ungoogled Chromium.app"] + ), + BrowserAvailabilityDescriptor( + shortKey: "aside", + displayName: "Aside", + bundleIdentifiers: ["at.studio.AsideBrowser"], + appNames: ["Aside.app"] + ), + ] + + struct BrowserStatus { + let key: String + let name: String + let bundleId: String + let path: String? + let installed: Bool + let running: Bool + } + + /// Resolves every known browser's install/running status. Read-only + /// (`NSWorkspace` + filesystem lookups); safe off-main. + static func detectStatuses( + runningApplications: [NSRunningApplication] = NSWorkspace.shared.runningApplications + ) -> [BrowserStatus] { + var runningByBundleId: [String: NSRunningApplication] = [:] + for app in runningApplications { + guard let bundleId = app.bundleIdentifier else { continue } + runningByBundleId[bundleId] = app + } + return knownBrowsers.map { descriptor in + let resolved = resolveApplicationPresence( + bundleIdentifiers: descriptor.bundleIdentifiers, + appNames: descriptor.appNames + ) + let bundleId = resolved.bundleIdentifier ?? descriptor.bundleIdentifiers.first ?? descriptor.shortKey + let runningApp = descriptor.bundleIdentifiers.compactMap { runningByBundleId[$0] }.first + ?? runningByBundleId[bundleId] + // A running app is authoritative proof of install even when the LS/path + // lookup misses it (nonstandard install location, mounted DMG, not yet + // Spotlight-indexed) -- otherwise running=true, installed=false is + // possible, which contradicts what "installed" means to callers. + return BrowserStatus( + key: descriptor.shortKey, + name: descriptor.displayName, + bundleId: bundleId, + path: resolved.url?.path ?? runningApp?.bundleURL?.path, + installed: resolved.url != nil || runningApp != nil, + running: runningApp != nil + ) + } + } + + /// Short key of the system default browser, resolved the same way for + /// both `PROGRAMA_DEFAULT_BROWSER` and `app.browsers`' `default` field: + /// one Launch Services call via `NSWorkspace.urlForApplication(toOpen:)`, + /// mapped through `shortKeysByBundleIdentifier`. + static func resolveDefaultBrowser() -> (shortKey: String, bundleIdentifier: String)? { + guard let exampleURL = URL(string: "https://example.com"), + let appURL = NSWorkspace.shared.urlForApplication(toOpen: exampleURL), + let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier else { + return nil + } + return (shortKey(forBundleIdentifier: bundleIdentifier), bundleIdentifier) + } + + private static func bundleIdentifier(for appURL: URL) -> String? { + Bundle(url: appURL)?.bundleIdentifier + } + + private static func resolveApplicationPresence( + bundleIdentifiers: [String], + appNames: [String] + ) -> (url: URL?, bundleIdentifier: String?) { + for knownBundleIdentifier in bundleIdentifiers { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: knownBundleIdentifier) { + return (appURL, bundleIdentifier(for: appURL) ?? knownBundleIdentifier) + } + } + let searchDirectories = defaultApplicationSearchDirectories() + for appName in appNames { + for directory in searchDirectories { + let appURL = directory.appendingPathComponent(appName, isDirectory: true) + if FileManager.default.fileExists(atPath: appURL.path) { + return (appURL, bundleIdentifier(for: appURL)) + } + } + } + return (nil, nil) + } + + private static func defaultApplicationSearchDirectories() -> [URL] { + let homeDirectoryURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + return [ + URL(fileURLWithPath: "/Applications", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true), + URL(fileURLWithPath: "/Applications/Setapp", isDirectory: true), + homeDirectoryURL.appendingPathComponent("Applications/Setapp", isDirectory: true), + ] + } +} diff --git a/Sources/Panels/BrowserDataImport.swift b/Sources/Panels/BrowserDataImport.swift deleted file mode 100644 index b70eb6eb..00000000 --- a/Sources/Panels/BrowserDataImport.swift +++ /dev/null @@ -1,3054 +0,0 @@ -// Extracted from BrowserPanel.swift (nuclear-review N6): the browser data -// import subsystem is self-contained — nothing here references the -// BrowserPanel class. - -import Foundation -import Combine -import WebKit -import AppKit -import Network -import CFNetwork -import SQLite3 -import CryptoKit -#if canImport(CommonCrypto) -import CommonCrypto -#endif -#if canImport(Security) -import Security -#endif - -fileprivate func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { - var seen = Set<String>() - var result: [URL] = [] - for url in urls { - let canonical = url.standardizedFileURL.resolvingSymlinksInPath().path - if seen.insert(canonical).inserted { - result.append(url) - } - } - return result -} - -enum BrowserImportScope: String, CaseIterable, Identifiable { - case cookiesOnly - case historyOnly - case cookiesAndHistory - case everything - - var id: String { rawValue } - - var displayName: String { - switch self { - case .cookiesOnly: - return String(localized: "browser.import.scope.cookiesOnly", defaultValue: "Cookies only") - case .historyOnly: - return String(localized: "browser.import.scope.historyOnly", defaultValue: "History only") - case .cookiesAndHistory: - return String(localized: "browser.import.scope.cookiesAndHistory", defaultValue: "Cookies + history") - case .everything: - return String(localized: "browser.import.scope.everything", defaultValue: "Everything") - } - } - - var includesCookies: Bool { - switch self { - case .cookiesOnly, .cookiesAndHistory, .everything: - return true - case .historyOnly: - return false - } - } - - var includesHistory: Bool { - switch self { - case .cookiesOnly: - return false - case .historyOnly, .cookiesAndHistory, .everything: - return true - } - } - - static func fromSelection( - includeCookies: Bool, - includeHistory: Bool, - includeAdditionalData: Bool - ) -> BrowserImportScope? { - if includeAdditionalData { - return .everything - } - guard includeCookies || includeHistory else { return nil } - if includeCookies && includeHistory { - return .cookiesAndHistory - } - if includeCookies { - return .cookiesOnly - } - return .historyOnly - } -} - -enum BrowserImportEngineFamily: String, Hashable { - case chromium - case firefox - case webkit -} - -struct InstalledBrowserProfile: Identifiable, Hashable { - let displayName: String - let rootURL: URL - let isDefault: Bool - - var id: String { - rootURL.standardizedFileURL.resolvingSymlinksInPath().path - } -} - -struct BrowserImportBrowserDescriptor: Hashable { - let id: String - let displayName: String - let family: BrowserImportEngineFamily - let tier: Int - let bundleIdentifiers: [String] - let appNames: [String] - let dataRootRelativePaths: [String] - let dataArtifactRelativePaths: [String] - let supportsDataOnlyDetection: Bool -} - -struct InstalledBrowserCandidate: Identifiable, Hashable { - let descriptor: BrowserImportBrowserDescriptor - let resolvedFamily: BrowserImportEngineFamily - let homeDirectoryURL: URL - let appURL: URL? - let dataRootURL: URL? - let profiles: [InstalledBrowserProfile] - let detectionSignals: [String] - let detectionScore: Int - - var id: String { descriptor.id } - var displayName: String { descriptor.displayName } - var family: BrowserImportEngineFamily { resolvedFamily } - var profileURLs: [URL] { profiles.map(\.rootURL) } -} - -struct BrowserAvailabilityDescriptor { - let shortKey: String - let displayName: String - let bundleIdentifiers: [String] - let appNames: [String] -} - -/// Availability info for `PROGRAMA_DEFAULT_BROWSER` (env var injected at shell -/// spawn, see `TerminalSurface.swift`) and the `app.browsers` socket command -/// (see `TerminalController+System.swift`). Deliberately independent of -/// `InstalledBrowserDetector.allBrowserDescriptors`/`detectInstalledBrowsers`, -/// which drive the browser data-import wizard: adding a browser here (Aside, -/// for instance) must never change what the import wizard offers, and -/// "installed" here means only "the app itself resolves" -- never the -/// wizard's leftover-profile-data scoring in `detectData`. -enum BrowserAvailability { - /// Bundle id -> short key used by both `PROGRAMA_DEFAULT_BROWSER` and the - /// `key` field of `app.browsers`. Falls back to the raw bundle id for - /// anything not listed here. - static let shortKeysByBundleIdentifier: [String: String] = [ - "com.apple.Safari": "safari", - "com.google.Chrome": "chrome", - "org.mozilla.firefox": "firefox", - "company.thebrowser.Browser": "arc", - "company.thebrowser.arc": "arc", - "com.brave.Browser": "brave", - "com.microsoft.edgemac": "edge", - "com.microsoft.Edge": "edge", - "app.zen-browser.zen": "zen", - "app.zen-browser.Zen": "zen", - "com.vivaldi.Vivaldi": "vivaldi", - "com.operasoftware.Opera": "opera", - "com.operasoftware.OperaGX": "opera-gx", - "com.kagi.kagimacOS": "orion", - "com.kagi.kagimacos": "orion", - "com.kagi.orion": "orion", - "company.thebrowser.Dia": "dia", - "company.thebrowser.dia": "dia", - "ai.perplexity.comet": "comet", - "one.ablaze.floorp": "floorp", - "net.waterfox.waterfox": "waterfox", - "com.feralcat.sigmaos": "sigmaos", - "com.meetsidekick.Sidekick": "sidekick", - "com.pushplaylabs.sidekick": "sidekick", - "net.imput.helium": "helium", - "com.jadenGeller.Helium": "helium", - "com.jaden.geller.helium": "helium", - "com.atlas.browser": "atlas", - "org.ladybird.Browser": "ladybird", - "org.serenityos.ladybird": "ladybird", - "org.chromium.Chromium": "chromium", - "org.chromium.ungoogled": "ungoogled-chromium", - "at.studio.AsideBrowser": "aside", - ] - - static func shortKey(forBundleIdentifier bundleIdentifier: String) -> String { - shortKeysByBundleIdentifier[bundleIdentifier] ?? bundleIdentifier - } - - /// Browsers `app.browsers` reports on: mirrored from - /// `InstalledBrowserDetector.allBrowserDescriptors` (bundle ids and app - /// names only -- never that list's data-directory paths or scoring), plus - /// Aside, which the import wizard does not know about. - static let knownBrowsers: [BrowserAvailabilityDescriptor] = { - let mirrored = InstalledBrowserDetector.allBrowserDescriptors.map { descriptor in - BrowserAvailabilityDescriptor( - shortKey: shortKey(forBundleIdentifier: descriptor.bundleIdentifiers.first ?? descriptor.id), - displayName: descriptor.displayName, - bundleIdentifiers: descriptor.bundleIdentifiers, - appNames: descriptor.appNames - ) - } - let aside = BrowserAvailabilityDescriptor( - shortKey: "aside", - displayName: "Aside", - bundleIdentifiers: ["at.studio.AsideBrowser"], - appNames: ["Aside.app"] - ) - return mirrored + [aside] - }() - - struct BrowserStatus { - let key: String - let name: String - let bundleId: String - let path: String? - let installed: Bool - let running: Bool - } - - /// Resolves every known browser's install/running status. Read-only - /// (`NSWorkspace` + filesystem lookups); safe off-main. - static func detectStatuses( - runningApplications: [NSRunningApplication] = NSWorkspace.shared.runningApplications - ) -> [BrowserStatus] { - var runningByBundleId: [String: NSRunningApplication] = [:] - for app in runningApplications { - guard let bundleId = app.bundleIdentifier else { continue } - runningByBundleId[bundleId] = app - } - return knownBrowsers.map { descriptor in - let resolved = InstalledBrowserDetector.resolveApplicationPresence( - bundleIdentifiers: descriptor.bundleIdentifiers, - appNames: descriptor.appNames - ) - let bundleId = resolved.bundleIdentifier ?? descriptor.bundleIdentifiers.first ?? descriptor.shortKey - let runningApp = descriptor.bundleIdentifiers.compactMap { runningByBundleId[$0] }.first - ?? runningByBundleId[bundleId] - // A running app is authoritative proof of install even when the LS/path - // lookup misses it (nonstandard install location, mounted DMG, not yet - // Spotlight-indexed) -- otherwise running=true, installed=false is - // possible, which contradicts what "installed" means to callers. - return BrowserStatus( - key: descriptor.shortKey, - name: descriptor.displayName, - bundleId: bundleId, - path: resolved.url?.path ?? runningApp?.bundleURL?.path, - installed: resolved.url != nil || runningApp != nil, - running: runningApp != nil - ) - } - } - - /// Short key of the system default browser, resolved the same way for - /// both `PROGRAMA_DEFAULT_BROWSER` and `app.browsers`' `default` field: - /// one Launch Services call via `NSWorkspace.urlForApplication(toOpen:)`, - /// mapped through `shortKeysByBundleIdentifier`. - static func resolveDefaultBrowser() -> (shortKey: String, bundleIdentifier: String)? { - guard let exampleURL = URL(string: "https://example.com"), - let appURL = NSWorkspace.shared.urlForApplication(toOpen: exampleURL), - let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier else { - return nil - } - return (shortKey(forBundleIdentifier: bundleIdentifier), bundleIdentifier) - } -} - -enum InstalledBrowserDetector { - typealias BundleLookup = (String) -> URL? - - static let allBrowserDescriptors: [BrowserImportBrowserDescriptor] = [ - BrowserImportBrowserDescriptor( - id: "safari", - displayName: "Safari", - family: .webkit, - tier: 1, - bundleIdentifiers: ["com.apple.Safari"], - appNames: ["Safari.app"], - dataRootRelativePaths: ["Library/Safari"], - dataArtifactRelativePaths: [ - "Library/Safari/History.db", - "Library/Cookies/Cookies.binarycookies", - ], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "google-chrome", - displayName: "Google Chrome", - family: .chromium, - tier: 1, - bundleIdentifiers: ["com.google.Chrome"], - appNames: ["Google Chrome.app"], - dataRootRelativePaths: ["Library/Application Support/Google/Chrome"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "firefox", - displayName: "Firefox", - family: .firefox, - tier: 1, - bundleIdentifiers: ["org.mozilla.firefox"], - appNames: ["Firefox.app"], - dataRootRelativePaths: ["Library/Application Support/Firefox"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "arc", - displayName: "Arc", - family: .chromium, - tier: 1, - bundleIdentifiers: ["company.thebrowser.Browser", "company.thebrowser.arc"], - appNames: ["Arc.app"], - dataRootRelativePaths: ["Library/Application Support/Arc"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "brave", - displayName: "Brave", - family: .chromium, - tier: 1, - bundleIdentifiers: ["com.brave.Browser"], - appNames: ["Brave Browser.app"], - dataRootRelativePaths: ["Library/Application Support/BraveSoftware/Brave-Browser"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "microsoft-edge", - displayName: "Microsoft Edge", - family: .chromium, - tier: 1, - bundleIdentifiers: ["com.microsoft.edgemac", "com.microsoft.Edge"], - appNames: ["Microsoft Edge.app"], - dataRootRelativePaths: ["Library/Application Support/Microsoft Edge"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "zen", - displayName: "Zen Browser", - family: .firefox, - tier: 2, - bundleIdentifiers: ["app.zen-browser.zen", "app.zen-browser.Zen"], - appNames: ["Zen Browser.app", "Zen.app"], - dataRootRelativePaths: ["Library/Application Support/Zen", "Library/Application Support/zen"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "vivaldi", - displayName: "Vivaldi", - family: .chromium, - tier: 2, - bundleIdentifiers: ["com.vivaldi.Vivaldi"], - appNames: ["Vivaldi.app"], - dataRootRelativePaths: ["Library/Application Support/Vivaldi"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "opera", - displayName: "Opera", - family: .chromium, - tier: 2, - bundleIdentifiers: ["com.operasoftware.Opera"], - appNames: ["Opera.app"], - dataRootRelativePaths: [ - "Library/Application Support/com.operasoftware.Opera", - "Library/Application Support/Opera", - ], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "opera-gx", - displayName: "Opera GX", - family: .chromium, - tier: 2, - bundleIdentifiers: ["com.operasoftware.OperaGX"], - appNames: ["Opera GX.app"], - dataRootRelativePaths: [ - "Library/Application Support/com.operasoftware.OperaGX", - "Library/Application Support/Opera GX Stable", - ], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "orion", - displayName: "Orion", - family: .webkit, - tier: 2, - bundleIdentifiers: ["com.kagi.kagimacOS", "com.kagi.kagimacos", "com.kagi.orion"], - appNames: ["Orion.app"], - dataRootRelativePaths: ["Library/Application Support/Orion"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "dia", - displayName: "Dia", - family: .chromium, - tier: 2, - bundleIdentifiers: ["company.thebrowser.Dia", "company.thebrowser.dia"], - appNames: ["Dia.app"], - dataRootRelativePaths: ["Library/Application Support/Dia"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "perplexity-comet", - displayName: "Perplexity Comet", - family: .chromium, - tier: 3, - bundleIdentifiers: ["ai.perplexity.comet"], - appNames: ["Perplexity Comet.app", "Comet.app"], - dataRootRelativePaths: ["Library/Application Support/Comet"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "floorp", - displayName: "Floorp", - family: .firefox, - tier: 3, - bundleIdentifiers: ["one.ablaze.floorp"], - appNames: ["Floorp.app"], - dataRootRelativePaths: ["Library/Application Support/Floorp"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "waterfox", - displayName: "Waterfox", - family: .firefox, - tier: 3, - bundleIdentifiers: ["net.waterfox.waterfox"], - appNames: ["Waterfox.app"], - dataRootRelativePaths: ["Library/Application Support/Waterfox"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "sigmaos", - displayName: "SigmaOS", - family: .chromium, - tier: 3, - bundleIdentifiers: ["com.feralcat.sigmaos"], - appNames: ["SigmaOS.app"], - dataRootRelativePaths: ["Library/Application Support/SigmaOS"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "sidekick", - displayName: "Sidekick", - family: .chromium, - tier: 3, - bundleIdentifiers: ["com.meetsidekick.Sidekick", "com.pushplaylabs.sidekick"], - appNames: ["Sidekick.app"], - dataRootRelativePaths: ["Library/Application Support/Sidekick"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "helium", - displayName: "Helium", - family: .chromium, - tier: 3, - bundleIdentifiers: ["net.imput.helium", "com.jadenGeller.Helium", "com.jaden.geller.helium"], - appNames: ["Helium.app"], - dataRootRelativePaths: [ - "Library/Application Support/net.imput.helium", - "Library/Application Support/Helium", - ], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "atlas", - displayName: "Atlas", - family: .chromium, - tier: 3, - bundleIdentifiers: ["com.atlas.browser"], - appNames: ["Atlas.app"], - dataRootRelativePaths: ["Library/Application Support/Atlas"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "ladybird", - displayName: "Ladybird", - family: .webkit, - tier: 3, - bundleIdentifiers: ["org.ladybird.Browser", "org.serenityos.ladybird"], - appNames: ["Ladybird.app"], - dataRootRelativePaths: ["Library/Application Support/Ladybird"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "chromium", - displayName: "Chromium", - family: .chromium, - tier: 3, - bundleIdentifiers: ["org.chromium.Chromium"], - appNames: ["Chromium.app"], - dataRootRelativePaths: ["Library/Application Support/Chromium"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: true - ), - BrowserImportBrowserDescriptor( - id: "ungoogled-chromium", - displayName: "Ungoogled Chromium", - family: .chromium, - tier: 3, - bundleIdentifiers: ["org.chromium.ungoogled"], - appNames: ["Ungoogled Chromium.app"], - dataRootRelativePaths: ["Library/Application Support/Chromium"], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: false - ), - ] - - static func detectInstalledBrowsers( - homeDirectoryURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), - bundleLookup: BundleLookup? = nil, - applicationSearchDirectories: [URL]? = nil, - fileManager: FileManager = .default - ) -> [InstalledBrowserCandidate] { - let lookup = bundleLookup ?? { bundleIdentifier in - NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) - } - let appSearchDirectories = applicationSearchDirectories ?? defaultApplicationSearchDirectories(homeDirectoryURL: homeDirectoryURL) - - let candidates = allBrowserDescriptors.compactMap { descriptor -> InstalledBrowserCandidate? in - let appDetection = detectApplication( - descriptor: descriptor, - appSearchDirectories: appSearchDirectories, - bundleLookup: lookup, - fileManager: fileManager - ) - - let dataDetection = detectData( - descriptor: descriptor, - homeDirectoryURL: homeDirectoryURL, - appBundleIdentifier: appDetection.bundleIdentifier, - fileManager: fileManager - ) - - if appDetection.url == nil, - !descriptor.supportsDataOnlyDetection { - return nil - } - - let hasData = dataDetection.dataRootURL != nil || !dataDetection.profiles.isEmpty || !dataDetection.artifactHits.isEmpty - guard appDetection.url != nil || hasData else { - return nil - } - - var score = 0 - if appDetection.url != nil { - score += 80 - } - if dataDetection.dataRootURL != nil { - score += 24 - } - score += min(24, dataDetection.profiles.count * 6) - score += min(16, dataDetection.artifactHits.count * 4) - - var signals: [String] = [] - signals.append(contentsOf: appDetection.signals) - if let root = dataDetection.dataRootURL { - signals.append("data:\(root.lastPathComponent)") - } - if !dataDetection.profiles.isEmpty { - signals.append("profiles:\(dataDetection.profiles.count)") - } - if !dataDetection.artifactHits.isEmpty { - signals.append(contentsOf: dataDetection.artifactHits.map { "artifact:\($0)" }) - } - - return InstalledBrowserCandidate( - descriptor: descriptor, - resolvedFamily: dataDetection.family, - homeDirectoryURL: homeDirectoryURL, - appURL: appDetection.url, - dataRootURL: dataDetection.dataRootURL, - profiles: dataDetection.profiles, - detectionSignals: signals, - detectionScore: score - ) - } - - return candidates.sorted { lhs, rhs in - if lhs.detectionScore != rhs.detectionScore { - return lhs.detectionScore > rhs.detectionScore - } - if lhs.descriptor.tier != rhs.descriptor.tier { - return lhs.descriptor.tier < rhs.descriptor.tier - } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending - } - } - - static func summaryText(for browsers: [InstalledBrowserCandidate], limit: Int = 4) -> String { - guard !browsers.isEmpty else { - return String( - localized: "browser.import.detected.none", - defaultValue: "No supported browsers detected." - ) - } - let names = browsers.map(\.displayName) - if names.count <= limit { - return String( - format: String( - localized: "browser.import.detected.all", - defaultValue: "Detected: %@." - ), - names.joined(separator: ", ") - ) - } - let shown = names.prefix(limit).joined(separator: ", ") - let remaining = names.count - limit - if remaining == 1 { - return String( - format: String( - localized: "browser.import.detected.more.one", - defaultValue: "Detected: %@, +1 more." - ), - shown - ) - } - return String( - format: String( - localized: "browser.import.detected.more.other", - defaultValue: "Detected: %@, +%ld more." - ), - shown, - remaining - ) - } - - private static func detectApplication( - descriptor: BrowserImportBrowserDescriptor, - appSearchDirectories: [URL], - bundleLookup: BundleLookup, - fileManager: FileManager - ) -> (url: URL?, signals: [String], bundleIdentifier: String?) { - for knownBundleIdentifier in descriptor.bundleIdentifiers { - if let appURL = bundleLookup(knownBundleIdentifier) { - return (appURL, ["bundle:\(knownBundleIdentifier)"], bundleIdentifier(for: appURL) ?? knownBundleIdentifier) - } - } - - for appName in descriptor.appNames { - for directory in appSearchDirectories { - let appURL = directory.appendingPathComponent(appName, isDirectory: true) - if fileManager.fileExists(atPath: appURL.path) { - return (appURL, ["app:\(appName)"], bundleIdentifier(for: appURL)) - } - } - } - - return (nil, [], nil) - } - - private static func detectData( - descriptor: BrowserImportBrowserDescriptor, - homeDirectoryURL: URL, - appBundleIdentifier: String?, - fileManager: FileManager - ) -> (dataRootURL: URL?, family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile], artifactHits: [String]) { - var bestRootURL: URL? - var bestFamily = descriptor.family - var bestProfiles: [InstalledBrowserProfile] = [] - var bestArtifacts: [String] = [] - let candidateRootPaths = candidateDataRootRelativePaths( - descriptor: descriptor, - appBundleIdentifier: appBundleIdentifier - ) - - for relativePath in candidateRootPaths { - let rootURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: true) - guard fileManager.fileExists(atPath: rootURL.path) else { continue } - - let detectedProfiles = detectProfiles( - descriptor: descriptor, - rootURL: rootURL, - homeDirectoryURL: homeDirectoryURL, - fileManager: fileManager - ) - - let score = scoreProfileDetection( - family: detectedProfiles.family, - profiles: detectedProfiles.profiles, - preferredFamily: descriptor.family - ) + 8 - let currentScore = scoreProfileDetection( - family: bestFamily, - profiles: bestProfiles, - preferredFamily: descriptor.family - ) + (bestRootURL == nil ? 0 : 8) - if score > currentScore { - bestRootURL = rootURL - bestFamily = detectedProfiles.family - bestProfiles = detectedProfiles.profiles - } - } - - var artifactHits: [String] = [] - for relativePath in descriptor.dataArtifactRelativePaths { - let artifactURL = homeDirectoryURL.appendingPathComponent(relativePath, isDirectory: false) - if fileManager.fileExists(atPath: artifactURL.path) { - artifactHits.append(artifactURL.lastPathComponent) - } - } - - if !artifactHits.isEmpty { - bestArtifacts = artifactHits - if bestRootURL == nil, - let rootPath = candidateRootPaths.first { - let rootURL = homeDirectoryURL.appendingPathComponent(rootPath, isDirectory: true) - if fileManager.fileExists(atPath: rootURL.path) { - bestRootURL = rootURL - } - } - } - - if bestProfiles.isEmpty, let bestRootURL { - bestProfiles = [ - InstalledBrowserProfile( - displayName: String(localized: "browser.profile.default", defaultValue: "Default"), - rootURL: bestRootURL, - isDefault: true - ) - ] - } - - return ( - dataRootURL: bestRootURL, - family: bestFamily, - profiles: sortProfiles(dedupedProfiles(bestProfiles)), - artifactHits: bestArtifacts - ) - } - - private static func detectProfiles( - descriptor: BrowserImportBrowserDescriptor, - rootURL: URL, - homeDirectoryURL: URL, - fileManager: FileManager - ) -> (family: BrowserImportEngineFamily, profiles: [InstalledBrowserProfile]) { - let candidates: [(BrowserImportEngineFamily, [InstalledBrowserProfile])] = [ - (.chromium, chromiumProfiles(rootURL: rootURL, fileManager: fileManager)), - (.firefox, firefoxProfiles(rootURL: rootURL, fileManager: fileManager)), - (.webkit, webKitProfiles( - descriptor: descriptor, - rootURL: rootURL, - homeDirectoryURL: homeDirectoryURL, - fileManager: fileManager - )), - ] - - return candidates.max { lhs, rhs in - let lhsScore = scoreProfileDetection( - family: lhs.0, - profiles: lhs.1, - preferredFamily: descriptor.family - ) - let rhsScore = scoreProfileDetection( - family: rhs.0, - profiles: rhs.1, - preferredFamily: descriptor.family - ) - if lhsScore != rhsScore { - return lhsScore < rhsScore - } - return lhs.0.rawValue > rhs.0.rawValue - } ?? (descriptor.family, []) - } - - private static func bundleIdentifier(for appURL: URL) -> String? { - Bundle(url: appURL)?.bundleIdentifier - } - - /// App-presence half of `detectApplication` above, mirrored (not shared - /// via refactor) for `BrowserAvailability` -- deliberately excludes - /// `detectData`'s leftover-profile-data scoring below. "Installed" here - /// means only that the app itself resolves, by bundle identifier first - /// then by app name across the standard search directories. - static func resolveApplicationPresence( - bundleIdentifiers: [String], - appNames: [String], - homeDirectoryURL: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), - bundleLookup: BundleLookup? = nil, - fileManager: FileManager = .default - ) -> (url: URL?, bundleIdentifier: String?) { - let lookup = bundleLookup ?? { bundleIdentifier in - NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) - } - for knownBundleIdentifier in bundleIdentifiers { - if let appURL = lookup(knownBundleIdentifier) { - return (appURL, bundleIdentifier(for: appURL) ?? knownBundleIdentifier) - } - } - let searchDirectories = defaultApplicationSearchDirectories(homeDirectoryURL: homeDirectoryURL) - for appName in appNames { - for directory in searchDirectories { - let appURL = directory.appendingPathComponent(appName, isDirectory: true) - if fileManager.fileExists(atPath: appURL.path) { - return (appURL, bundleIdentifier(for: appURL)) - } - } - } - return (nil, nil) - } - - private static func candidateDataRootRelativePaths( - descriptor: BrowserImportBrowserDescriptor, - appBundleIdentifier: String? - ) -> [String] { - var result: [String] = [] - var seen = Set<String>() - - func append(_ relativePath: String) { - if seen.insert(relativePath).inserted { - result.append(relativePath) - } - } - - for relativePath in descriptor.dataRootRelativePaths { - append(relativePath) - } - - let bundleIdentifiers = [appBundleIdentifier].compactMap { $0 } + descriptor.bundleIdentifiers - for bundleIdentifier in bundleIdentifiers { - append("Library/Application Support/\(bundleIdentifier)") - append("Library/Containers/\(bundleIdentifier)/Data/Library/Application Support/\(bundleIdentifier)") - } - - return result - } - - private static func scoreProfileDetection( - family: BrowserImportEngineFamily, - profiles: [InstalledBrowserProfile], - preferredFamily: BrowserImportEngineFamily - ) -> Int { - var score = profiles.count * 10 - if family == preferredFamily { - score += 3 - } - if profiles.contains(where: \.isDefault) { - score += 1 - } - return score - } - - private static func chromiumProfiles( - rootURL: URL, - fileManager: FileManager - ) -> [InstalledBrowserProfile] { - let nameMap = chromiumProfileNameMap(rootURL: rootURL) - var profiles: [InstalledBrowserProfile] = [] - if looksLikeChromiumProfile(rootURL: rootURL, fileManager: fileManager) { - profiles.append( - InstalledBrowserProfile( - displayName: chromiumProfileDisplayName( - directoryName: rootURL.lastPathComponent, - nameMap: nameMap, - isDefault: true - ), - rootURL: rootURL, - isDefault: true - ) - ) - } - - let children = (try? fileManager.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - )) ?? [] - - for child in children { - guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } - let name = child.lastPathComponent - let isLikelyProfile = - name == "Default" || - name.hasPrefix("Profile ") || - name.hasPrefix("Guest Profile") || - name.hasPrefix("Person ") || - nameMap[name] != nil - if isLikelyProfile && looksLikeChromiumProfile(rootURL: child, fileManager: fileManager) { - profiles.append( - InstalledBrowserProfile( - displayName: chromiumProfileDisplayName( - directoryName: name, - nameMap: nameMap, - isDefault: name == "Default" - ), - rootURL: child, - isDefault: name == "Default" - ) - ) - } - } - - return sortProfiles(dedupedProfiles(profiles)) - } - - private static func firefoxProfiles( - rootURL: URL, - fileManager: FileManager - ) -> [InstalledBrowserProfile] { - var profiles = firefoxProfilesFromINI(rootURL: rootURL, fileManager: fileManager) - - let likelyProfileRoots = [ - rootURL.appendingPathComponent("Profiles", isDirectory: true), - rootURL, - ] - - for directory in likelyProfileRoots where fileManager.fileExists(atPath: directory.path) { - let children = (try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - )) ?? [] - for child in children { - guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } - if looksLikeFirefoxProfile(rootURL: child, fileManager: fileManager) { - let directoryName = child.lastPathComponent - profiles.append( - InstalledBrowserProfile( - displayName: directoryName, - rootURL: child, - isDefault: directoryName.localizedCaseInsensitiveContains("default") - ) - ) - } - } - } - - return sortProfiles(dedupedProfiles(profiles)) - } - - private static func firefoxProfilesFromINI( - rootURL: URL, - fileManager: FileManager - ) -> [InstalledBrowserProfile] { - let iniURL = rootURL.appendingPathComponent("profiles.ini", isDirectory: false) - guard let contents = try? String(contentsOf: iniURL, encoding: .utf8) else { - return [] - } - - let sections = parseINISections(contents: contents) - var profiles: [InstalledBrowserProfile] = [] - for section in sections { - guard let pathValue = section["Path"], !pathValue.isEmpty else { continue } - let isRelative = section["IsRelative"] != "0" - let profileURL: URL - if isRelative { - profileURL = rootURL.appendingPathComponent(pathValue, isDirectory: true) - } else { - profileURL = URL(fileURLWithPath: pathValue, isDirectory: true) - } - if looksLikeFirefoxProfile(rootURL: profileURL, fileManager: fileManager) { - let displayName = section["Name"]?.trimmingCharacters(in: .whitespacesAndNewlines) - profiles.append( - InstalledBrowserProfile( - displayName: (displayName?.isEmpty == false ? displayName! : profileURL.lastPathComponent), - rootURL: profileURL, - isDefault: section["Default"] == "1" - ) - ) - } - } - return profiles - } - - private static func parseINISections(contents: String) -> [[String: String]] { - var sections: [[String: String]] = [] - var current: [String: String] = [:] - - func flushCurrent() { - if !current.isEmpty { - sections.append(current) - current.removeAll() - } - } - - for line in contents.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty || trimmed.hasPrefix(";") || trimmed.hasPrefix("#") { - continue - } - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - flushCurrent() - continue - } - guard let separator = trimmed.firstIndex(of: "=") else { continue } - let key = String(trimmed[..<separator]).trimmingCharacters(in: .whitespacesAndNewlines) - let value = String(trimmed[trimmed.index(after: separator)...]).trimmingCharacters(in: .whitespacesAndNewlines) - current[key] = value - } - flushCurrent() - return sections - } - - private static func looksLikeChromiumProfile(rootURL: URL, fileManager: FileManager) -> Bool { - let historyURL = rootURL.appendingPathComponent("History", isDirectory: false) - let cookiesURL = rootURL.appendingPathComponent("Cookies", isDirectory: false) - return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) - } - - private static func looksLikeFirefoxProfile(rootURL: URL, fileManager: FileManager) -> Bool { - let historyURL = rootURL.appendingPathComponent("places.sqlite", isDirectory: false) - let cookiesURL = rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) - return fileManager.fileExists(atPath: historyURL.path) || fileManager.fileExists(atPath: cookiesURL.path) - } - - private static func webKitProfiles( - descriptor: BrowserImportBrowserDescriptor, - rootURL: URL, - homeDirectoryURL: URL, - fileManager: FileManager - ) -> [InstalledBrowserProfile] { - var profiles: [InstalledBrowserProfile] = [] - if looksLikeWebKitProfile(rootURL: rootURL, fileManager: fileManager) { - profiles.append( - InstalledBrowserProfile( - displayName: String(localized: "browser.profile.default", defaultValue: "Default"), - rootURL: rootURL, - isDefault: true - ) - ) - } - - var profileRoots = [rootURL.appendingPathComponent("Profiles", isDirectory: true)] - if descriptor.id == "safari" { - profileRoots.append( - homeDirectoryURL - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Containers", isDirectory: true) - .appendingPathComponent("com.apple.Safari", isDirectory: true) - .appendingPathComponent("Data", isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Safari", isDirectory: true) - .appendingPathComponent("Profiles", isDirectory: true) - ) - } - - var profileIndex = 1 - for profileRoot in dedupedCanonicalURLs(profileRoots) where fileManager.fileExists(atPath: profileRoot.path) { - let children = (try? fileManager.contentsOfDirectory( - at: profileRoot, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - )) ?? [] - for child in children { - guard (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } - guard looksLikeWebKitProfile(rootURL: child, fileManager: fileManager) else { continue } - profiles.append( - InstalledBrowserProfile( - displayName: webKitProfileDisplayName( - directoryName: child.lastPathComponent, - fallbackIndex: profileIndex - ), - rootURL: child, - isDefault: false - ) - ) - profileIndex += 1 - } - } - - return sortProfiles(dedupedProfiles(profiles)) - } - - private static func chromiumProfileNameMap(rootURL: URL) -> [String: String] { - let localStateURL = rootURL.appendingPathComponent("Local State", isDirectory: false) - guard let data = try? Data(contentsOf: localStateURL), - let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let profileSection = jsonObject["profile"] as? [String: Any], - let infoCache = profileSection["info_cache"] as? [String: Any] else { - return [:] - } - - var result: [String: String] = [:] - for (directoryName, rawProfileInfo) in infoCache { - guard let profileInfo = rawProfileInfo as? [String: Any], - let name = profileInfo["name"] as? String else { - continue - } - let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmedName.isEmpty { - result[directoryName] = trimmedName - } - } - return result - } - - private static func chromiumProfileDisplayName( - directoryName: String, - nameMap: [String: String], - isDefault: Bool - ) -> String { - if let mappedName = nameMap[directoryName], !mappedName.isEmpty { - return mappedName - } - if isDefault { - return String(localized: "browser.profile.default", defaultValue: "Default") - } - return directoryName - } - - private static func looksLikeWebKitProfile(rootURL: URL, fileManager: FileManager) -> Bool { - let candidatePaths = [ - "History.db", - "Cookies.binarycookies", - "Cookies.sqlite", - "WebsiteData", - "LocalStorage", - ] - - for candidatePath in candidatePaths { - let url = rootURL.appendingPathComponent(candidatePath, isDirectory: candidatePath != "History.db" && candidatePath != "Cookies.binarycookies" && candidatePath != "Cookies.sqlite") - if fileManager.fileExists(atPath: url.path) { - return true - } - } - return false - } - - private static func webKitProfileDisplayName(directoryName: String, fallbackIndex: Int) -> String { - if directoryName.caseInsensitiveCompare("Default") == .orderedSame { - return String(localized: "browser.profile.default", defaultValue: "Default") - } - if UUID(uuidString: directoryName) != nil { - return String( - format: String( - localized: "browser.import.sourceProfile.fallback", - defaultValue: "Profile %ld" - ), - fallbackIndex - ) - } - return directoryName - } - - private static func defaultApplicationSearchDirectories(homeDirectoryURL: URL) -> [URL] { - [ - URL(fileURLWithPath: "/Applications", isDirectory: true), - homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true), - URL(fileURLWithPath: "/Applications/Setapp", isDirectory: true), - homeDirectoryURL.appendingPathComponent("Applications/Setapp", isDirectory: true), - ] - } - - private static func dedupedProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { - var seen = Set<String>() - var result: [InstalledBrowserProfile] = [] - for profile in profiles { - if seen.insert(profile.id).inserted { - result.append(profile) - } - } - return result - } - - private static func sortProfiles(_ profiles: [InstalledBrowserProfile]) -> [InstalledBrowserProfile] { - profiles.sorted { lhs, rhs in - if lhs.isDefault != rhs.isDefault { - return lhs.isDefault && !rhs.isDefault - } - let comparison = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) - if comparison != .orderedSame { - return comparison == .orderedAscending - } - return lhs.id < rhs.id - } - } -} - -struct BrowserImportOutcomeEntry: Sendable { - let sourceProfileNames: [String] - let destinationProfileName: String - let importedCookies: Int - let skippedCookies: Int - let importedHistoryEntries: Int - let warnings: [String] -} - -struct BrowserImportOutcome: Sendable { - let browserName: String - let scope: BrowserImportScope - let domainFilters: [String] - let createdDestinationProfileNames: [String] - let entries: [BrowserImportOutcomeEntry] - let warnings: [String] - - var totalImportedCookies: Int { - entries.reduce(0) { $0 + $1.importedCookies } - } - - var totalSkippedCookies: Int { - entries.reduce(0) { $0 + $1.skippedCookies } - } - - var totalImportedHistoryEntries: Int { - entries.reduce(0) { $0 + $1.importedHistoryEntries } - } -} - -struct RealizedBrowserImportExecutionEntry: Sendable { - let sourceProfiles: [InstalledBrowserProfile] - let destinationProfileID: UUID - let destinationProfileName: String -} - -struct RealizedBrowserImportExecutionPlan: Sendable { - let mode: BrowserImportDestinationMode - let entries: [RealizedBrowserImportExecutionEntry] - let createdProfiles: [BrowserProfileDefinition] -} - -enum BrowserImportPlanRealizationError: LocalizedError { - case missingDestinationProfile(UUID) - case profileCreationFailed(String) - - var errorDescription: String? { - switch self { - case .missingDestinationProfile: - return String( - localized: "browser.import.error.destinationMissing", - defaultValue: "The selected Programa browser profile no longer exists. Pick a destination profile again." - ) - case .profileCreationFailed(let name): - return String( - format: String( - localized: "browser.import.error.destinationCreateFailed", - defaultValue: "Programa could not create the destination profile \"%@\"." - ), - name - ) - } - } -} - -enum BrowserImportOutcomeFormatter { - static func lines(for outcome: BrowserImportOutcome) -> [String] { - var lines: [String] = [] - lines.append( - String( - format: String( - localized: "browser.import.complete.browser", - defaultValue: "Browser: %@" - ), - outcome.browserName - ) - ) - - if outcome.entries.count == 1, let entry = outcome.entries.first { - if !entry.sourceProfileNames.isEmpty { - lines.append( - String( - format: String( - localized: "browser.import.complete.sourceProfiles", - defaultValue: "Source profiles: %@" - ), - entry.sourceProfileNames.joined(separator: ", ") - ) - ) - } - lines.append( - String( - format: String( - localized: "browser.import.complete.destinationProfile", - defaultValue: "Destination profile: %@" - ), - entry.destinationProfileName - ) - ) - } else if !outcome.entries.isEmpty { - lines.append( - String( - localized: "browser.import.complete.profileMappings", - defaultValue: "Profile mappings:" - ) - ) - for entry in outcome.entries { - let sourceNames = entry.sourceProfileNames.joined(separator: ", ") - lines.append( - String( - format: String( - localized: "browser.import.complete.profileMapping", - defaultValue: "%@ -> %@" - ), - sourceNames, - entry.destinationProfileName - ) - ) - } - } - - lines.append( - String( - format: String( - localized: "browser.import.complete.scope", - defaultValue: "Scope: %@" - ), - outcome.scope.displayName - ) - ) - lines.append( - String( - format: String( - localized: "browser.import.complete.importedCookies", - defaultValue: "Imported cookies: %ld" - ), - outcome.totalImportedCookies - ) - ) - if outcome.totalSkippedCookies > 0 { - lines.append( - String( - format: String( - localized: "browser.import.complete.skippedCookies", - defaultValue: "Skipped cookies: %ld" - ), - outcome.totalSkippedCookies - ) - ) - } - if outcome.scope.includesHistory { - lines.append( - String( - format: String( - localized: "browser.import.complete.importedHistory", - defaultValue: "Imported history entries: %ld" - ), - outcome.totalImportedHistoryEntries - ) - ) - } - if !outcome.domainFilters.isEmpty { - lines.append( - String( - format: String( - localized: "browser.import.complete.domainFilter", - defaultValue: "Domain filter: %@" - ), - outcome.domainFilters.joined(separator: ", ") - ) - ) - } - if !outcome.createdDestinationProfileNames.isEmpty { - lines.append( - String( - format: String( - localized: "browser.import.complete.createdProfiles", - defaultValue: "Created Programa profiles: %@" - ), - outcome.createdDestinationProfileNames.joined(separator: ", ") - ) - ) - } - if !outcome.warnings.isEmpty { - lines.append("") - lines.append( - String( - localized: "browser.import.complete.warnings", - defaultValue: "Warnings:" - ) - ) - for warning in outcome.warnings { - lines.append("- \(warning)") - } - } - - return lines - } -} - -enum BrowserImportDestinationMode: Equatable, Sendable { - case singleDestination - case separateProfiles - case mergeIntoOne -} - -enum BrowserImportDestinationRequest: Equatable, Sendable { - case existing(UUID) - case createNamed(String) -} - -struct BrowserImportExecutionEntry: Equatable, Sendable { - var sourceProfiles: [InstalledBrowserProfile] - var destination: BrowserImportDestinationRequest -} - -struct BrowserImportExecutionPlan: Equatable, Sendable { - var mode: BrowserImportDestinationMode - var entries: [BrowserImportExecutionEntry] -} - -struct BrowserImportStep3Presentation: Equatable { - let showsModeSelector: Bool - let showsSeparateRows: Bool - let showsSingleDestinationPicker: Bool - - init(plan: BrowserImportExecutionPlan) { - showsModeSelector = plan.entries.count > 1 || plan.entries.contains { $0.sourceProfiles.count > 1 } - showsSeparateRows = plan.mode == .separateProfiles - showsSingleDestinationPicker = plan.mode != .separateProfiles - } -} - -struct BrowserImportSourceProfilesPresentation: Equatable { - let scrollHeight: CGFloat - let showsHelpText: Bool - - init(profileCount: Int) { - let visibleRows = min(max(profileCount, 1), 5) - let contentHeight = CGFloat(visibleRows * 26 + 14) - scrollHeight = max(76, contentHeight) - showsHelpText = profileCount > 1 - } -} - -enum BrowserImportPlanResolver { - @MainActor - static func defaultPlan( - selectedSourceProfiles: [InstalledBrowserProfile], - destinationProfiles: [BrowserProfileDefinition], - preferredSingleDestinationProfileID: UUID - ) -> BrowserImportExecutionPlan { - let resolvedSourceProfiles = selectedSourceProfiles.isEmpty ? [] : selectedSourceProfiles - - guard resolvedSourceProfiles.count > 1 else { - let destinationRequest: BrowserImportDestinationRequest - if let sourceProfile = resolvedSourceProfiles.first, - let matchingProfile = matchingDestinationProfile( - for: sourceProfile.displayName, - destinationProfiles: destinationProfiles - ) { - destinationRequest = .existing(matchingProfile.id) - } else { - destinationRequest = .existing(preferredSingleDestinationProfileID) - } - - return BrowserImportExecutionPlan( - mode: .singleDestination, - entries: resolvedSourceProfiles.map { - BrowserImportExecutionEntry( - sourceProfiles: [$0], - destination: destinationRequest - ) - } - ) - } - - return separateProfilesPlan( - selectedSourceProfiles: resolvedSourceProfiles, - destinationProfiles: destinationProfiles - ) - } - - static func separateProfilesPlan( - selectedSourceProfiles: [InstalledBrowserProfile], - destinationProfiles: [BrowserProfileDefinition] - ) -> BrowserImportExecutionPlan { - var reservedNames = Set(destinationProfiles.map { normalizedProfileName($0.displayName) }) - - return BrowserImportExecutionPlan( - mode: .separateProfiles, - entries: selectedSourceProfiles.map { profile in - if let matchingProfile = matchingDestinationProfile( - for: profile.displayName, - destinationProfiles: destinationProfiles - ) { - return BrowserImportExecutionEntry( - sourceProfiles: [profile], - destination: .existing(matchingProfile.id) - ) - } - - let createName = nextCreateName( - baseName: profile.displayName, - takenNames: reservedNames - ) - reservedNames.insert(normalizedProfileName(createName)) - return BrowserImportExecutionEntry( - sourceProfiles: [profile], - destination: .createNamed(createName) - ) - } - ) - } - - private static func matchingDestinationProfile( - for sourceProfileName: String, - destinationProfiles: [BrowserProfileDefinition] - ) -> BrowserProfileDefinition? { - let normalizedSourceName = normalizedProfileName(sourceProfileName) - guard !normalizedSourceName.isEmpty else { return nil } - return destinationProfiles.first { - normalizedProfileName($0.displayName) == normalizedSourceName - } - } - - private static func nextCreateName( - baseName: String, - takenNames: Set<String> - ) -> String { - let trimmedBaseName = baseName.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedBaseName = trimmedBaseName.isEmpty ? "Profile" : trimmedBaseName - if !takenNames.contains(normalizedProfileName(resolvedBaseName)) { - return resolvedBaseName - } - - var suffix = 2 - while true { - let candidate = "\(resolvedBaseName) (\(suffix))" - if !takenNames.contains(normalizedProfileName(candidate)) { - return candidate - } - suffix += 1 - } - } - - private static func normalizedProfileName(_ rawName: String) -> String { - rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - } - - @MainActor - static func realize( - plan: BrowserImportExecutionPlan, - profileStore: BrowserProfileStore - ) throws -> RealizedBrowserImportExecutionPlan { - var realizedEntries: [RealizedBrowserImportExecutionEntry] = [] - var createdProfiles: [BrowserProfileDefinition] = [] - - for entry in plan.entries { - let destinationProfile: BrowserProfileDefinition - switch entry.destination { - case .existing(let id): - guard let existingProfile = profileStore.profileDefinition(id: id) else { - throw BrowserImportPlanRealizationError.missingDestinationProfile(id) - } - destinationProfile = existingProfile - case .createNamed(let name): - if let existingProfile = matchingDestinationProfile( - for: name, - destinationProfiles: profileStore.profiles - ) { - destinationProfile = existingProfile - } else if let createdProfile = profileStore.createProfile(named: name) { - createdProfiles.append(createdProfile) - destinationProfile = createdProfile - } else { - throw BrowserImportPlanRealizationError.profileCreationFailed(name) - } - } - - realizedEntries.append( - RealizedBrowserImportExecutionEntry( - sourceProfiles: entry.sourceProfiles, - destinationProfileID: destinationProfile.id, - destinationProfileName: destinationProfile.displayName - ) - ) - } - - return RealizedBrowserImportExecutionPlan( - mode: plan.mode, - entries: realizedEntries, - createdProfiles: createdProfiles - ) - } - - @MainActor - static func realize(plan: BrowserImportExecutionPlan) throws -> RealizedBrowserImportExecutionPlan { - try realize(plan: plan, profileStore: .shared) - } -} - -#if canImport(CommonCrypto) && canImport(Security) -private struct ChromiumCookieKeychainItem: Hashable { - let service: String - let account: String -} - -private final class ChromiumCookieDecryptor { - private enum KeychainLookupResult { - case success(Data) - case failure(OSStatus) - } - - enum FailureReason { - case keychain(OSStatus) - case itemNotFound - case unreadableSecret - case decrypt - case unsupportedFormat - } - - private let browser: InstalledBrowserCandidate - private var cachedKeychainItem: ChromiumCookieKeychainItem? - private var cachedPasswordData: Data? - private var attemptedLookup = false - private(set) var lastFailureReason: FailureReason? - - init(browser: InstalledBrowserCandidate) { - self.browser = browser - } - - var resolvedKeychainItemName: String? { - cachedKeychainItem?.service - } - - func decryptCookieValue(encryptedValue: Data, host: String) -> String? { - guard let versionPrefix = chromiumVersionPrefix(in: encryptedValue) else { - lastFailureReason = .unsupportedFormat - return nil - } - - guard let passwordData = passwordData() else { - return nil - } - - let ciphertext = encryptedValue.dropFirst(versionPrefix.count) - guard let key = deriveKey(from: passwordData), - let plaintext = decrypt(ciphertext: Data(ciphertext), key: key), - let cookieValue = decodePlaintext(plaintext, host: host) else { - lastFailureReason = .decrypt - return nil - } - - lastFailureReason = nil - return cookieValue - } - - func warningMessage(browserName: String, skippedCount: Int) -> String? { - guard skippedCount > 0, let failure = lastFailureReason else { return nil } - switch failure { - case .keychain, .itemNotFound, .unreadableSecret: - let itemName = resolvedKeychainItemName ?? suggestedKeychainItems().first?.service ?? "\(browserName) Storage Key" - return String( - format: String( - localized: "browser.import.warning.keychainDecryptFailed", - defaultValue: "Skipped %ld encrypted %@ cookies because %@ could not be unlocked from Keychain." - ), - skippedCount, - browserName, - itemName - ) - case .decrypt, .unsupportedFormat: - return String( - format: String( - localized: "browser.import.warning.encryptedCookiesSkipped", - defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." - ), - skippedCount - ) - } - } - - private func passwordData() -> Data? { - if let cachedPasswordData { - return cachedPasswordData - } - guard !attemptedLookup else { - return nil - } - attemptedLookup = true - - for item in suggestedKeychainItems() { - switch readPasswordData(item: item) { - case .success(let passwordData): - guard !passwordData.isEmpty else { - cachedKeychainItem = item - lastFailureReason = .unreadableSecret - return nil - } - cachedKeychainItem = item - cachedPasswordData = passwordData - lastFailureReason = nil - return passwordData - case .failure(let status): - if status == errSecItemNotFound { - continue - } - cachedKeychainItem = item - lastFailureReason = .keychain(status) - return nil - } - } - - lastFailureReason = .itemNotFound - return nil - } - - private func suggestedKeychainItems() -> [ChromiumCookieKeychainItem] { - var result: [ChromiumCookieKeychainItem] = [] - var seen = Set<ChromiumCookieKeychainItem>() - - func append(service: String, account: String) { - let trimmedService = service.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedAccount = account.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedService.isEmpty, !trimmedAccount.isEmpty else { return } - let item = ChromiumCookieKeychainItem(service: trimmedService, account: trimmedAccount) - if seen.insert(item).inserted { - result.append(item) - } - } - - for baseName in keychainBaseNames() { - append(service: "\(baseName) Storage Key", account: baseName) - append(service: "\(baseName) Safe Storage", account: baseName) - } - - for baseName in keychainBaseNames() { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: baseName, - kSecReturnAttributes: true, - kSecMatchLimit: kSecMatchLimitAll, - ] - var rawResult: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &rawResult) - guard status == errSecSuccess else { continue } - let attributesList = rawResult as? [[String: Any]] ?? [] - for attributes in attributesList { - guard let service = attributes[kSecAttrService as String] as? String else { continue } - guard service.contains("Storage Key") || service.contains("Safe Storage") else { continue } - append(service: service, account: baseName) - } - } - - return result - } - - private func keychainBaseNames() -> [String] { - var result: [String] = [] - var seen = Set<String>() - - func append(_ rawName: String?) { - guard let rawName else { return } - let trimmedName = rawName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedName.isEmpty else { return } - if seen.insert(trimmedName).inserted { - result.append(trimmedName) - } - } - - append(browser.displayName) - append(browser.appURL?.deletingPathExtension().lastPathComponent) - append(browser.descriptor.appNames.first?.replacingOccurrences(of: ".app", with: "")) - - if let appURL = browser.appURL, - let bundle = Bundle(url: appURL) { - append(bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) - append(bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) - } - - for name in Array(result) { - if name.hasPrefix("Google ") { - append(String(name.dropFirst("Google ".count))) - } - if name.hasSuffix(" Browser") { - append(String(name.dropLast(" Browser".count))) - } - } - - switch browser.descriptor.id { - case "google-chrome": - append("Chrome") - case "chromium": - append("Chromium") - case "brave": - append("Brave") - case "helium": - append("Helium") - default: - break - } - - return result - } - - private func readPasswordData(item: ChromiumCookieKeychainItem) -> KeychainLookupResult { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: item.service, - kSecAttrAccount: item.account, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitOne, - ] - - var rawResult: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &rawResult) - guard status == errSecSuccess else { - return .failure(status) - } - guard let passwordData = rawResult as? Data else { - return .failure(errSecDecode) - } - return .success(passwordData) - } - - private func chromiumVersionPrefix(in encryptedValue: Data) -> Data? { - for prefix in [Data("v10".utf8), Data("v11".utf8)] where encryptedValue.starts(with: prefix) { - return prefix - } - return nil - } - - private func deriveKey(from passwordData: Data) -> Data? { - let salt = Data("saltysalt".utf8) - var derivedKey = Data(count: kCCKeySizeAES128) - - let status = derivedKey.withUnsafeMutableBytes { derivedBytes in - passwordData.withUnsafeBytes { passwordBytes in - salt.withUnsafeBytes { saltBytes in - CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - passwordBytes.baseAddress?.assumingMemoryBound(to: Int8.self), - passwordData.count, - saltBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), - salt.count, - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), - 1003, - derivedBytes.baseAddress?.assumingMemoryBound(to: UInt8.self), - kCCKeySizeAES128 - ) - } - } - } - - guard status == kCCSuccess else { return nil } - return derivedKey - } - - private func decrypt(ciphertext: Data, key: Data) -> Data? { - let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) - var plaintext = Data(count: ciphertext.count + kCCBlockSizeAES128) - var plaintextLength = 0 - let plaintextCapacity = plaintext.count - - let status = plaintext.withUnsafeMutableBytes { plaintextBytes in - ciphertext.withUnsafeBytes { ciphertextBytes in - key.withUnsafeBytes { keyBytes in - iv.withUnsafeBytes { ivBytes in - CCCrypt( - CCOperation(kCCDecrypt), - CCAlgorithm(kCCAlgorithmAES), - CCOptions(kCCOptionPKCS7Padding), - keyBytes.baseAddress, - key.count, - ivBytes.baseAddress, - ciphertextBytes.baseAddress, - ciphertext.count, - plaintextBytes.baseAddress, - plaintextCapacity, - &plaintextLength - ) - } - } - } - } - - guard status == kCCSuccess else { return nil } - plaintext.removeSubrange(plaintextLength...) - return plaintext - } - - private func decodePlaintext(_ plaintext: Data, host: String) -> String? { - if let value = String(data: plaintext, encoding: .utf8) { - return value - } - - let hostDigest = Data(SHA256.hash(data: Data(host.utf8))) - if plaintext.starts(with: hostDigest) { - return String(data: plaintext.dropFirst(hostDigest.count), encoding: .utf8) - } - - return nil - } -} -#else -private final class ChromiumCookieDecryptor { - init(browser: InstalledBrowserCandidate) {} - - func decryptCookieValue(encryptedValue: Data, host: String) -> String? { nil } - - func warningMessage(browserName: String, skippedCount: Int) -> String? { - guard skippedCount > 0 else { return nil } - return String( - format: String( - localized: "browser.import.warning.encryptedCookiesSkipped", - defaultValue: "Skipped %ld encrypted cookies that require Keychain decryption." - ), - skippedCount - ) - } -} -#endif - -enum BrowserDataImporter { - private struct CookieImportResult { - var importedCount: Int = 0 - var skippedCount: Int = 0 - var warnings: [String] = [] - } - - private struct HistoryImportResult { - var importedCount: Int = 0 - var warnings: [String] = [] - } - - private struct HistoryRow { - let url: String - let title: String? - let visitCount: Int - let lastVisited: Date - } - - static func parseDomainFilters(_ raw: String) -> [String] { - var result: [String] = [] - var seen = Set<String>() - let separators = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ",;")) - for token in raw.components(separatedBy: separators) { - var value = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if value.hasPrefix("*.") { - value.removeFirst(2) - } - while value.hasPrefix(".") { - value.removeFirst() - } - guard let canonicalValue = canonicalDomain(value) else { continue } - guard seen.insert(canonicalValue).inserted else { continue } - result.append(canonicalValue) - } - return result - } - - static func importData( - from browser: InstalledBrowserCandidate, - plan: RealizedBrowserImportExecutionPlan, - scope: BrowserImportScope, - domainFilters: [String] - ) async -> BrowserImportOutcome { - var outcomeEntries: [BrowserImportOutcomeEntry] = [] - var warnings: [String] = [] - var seenWarnings = Set<String>() - - for entry in plan.entries { - let outcomeEntry = await importEntry( - from: browser, - sourceProfiles: entry.sourceProfiles, - destinationProfileID: entry.destinationProfileID, - destinationProfileName: entry.destinationProfileName, - scope: scope, - domainFilters: domainFilters - ) - outcomeEntries.append(outcomeEntry) - for warning in outcomeEntry.warnings where seenWarnings.insert(warning).inserted { - warnings.append(warning) - } - } - - if scope == .everything { - let unavailableWarning = String( - localized: "browser.import.warning.additionalDataUnavailable", - defaultValue: "Bookmarks, settings, and extensions import are not available yet. Imported cookies and history only." - ) - if seenWarnings.insert(unavailableWarning).inserted { - warnings.append(unavailableWarning) - } - } - - return BrowserImportOutcome( - browserName: browser.displayName, - scope: scope, - domainFilters: domainFilters, - createdDestinationProfileNames: plan.createdProfiles.map(\.displayName), - entries: outcomeEntries, - warnings: warnings - ) - } - - private static func importEntry( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - destinationProfileName: String, - scope: BrowserImportScope, - domainFilters: [String] - ) async -> BrowserImportOutcomeEntry { - let resolvedSourceProfiles = sourceProfiles.isEmpty ? browser.profiles : sourceProfiles - var cookieResult = CookieImportResult() - if scope.includesCookies { - cookieResult = await importCookies( - from: browser, - sourceProfiles: resolvedSourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - } - - var historyResult = HistoryImportResult() - if scope.includesHistory { - historyResult = await importHistory( - from: browser, - sourceProfiles: resolvedSourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - } - - var warnings = cookieResult.warnings - warnings.append(contentsOf: historyResult.warnings) - return BrowserImportOutcomeEntry( - sourceProfileNames: resolvedSourceProfiles.map(\.displayName), - destinationProfileName: destinationProfileName, - importedCookies: cookieResult.importedCount, - skippedCookies: cookieResult.skippedCount, - importedHistoryEntries: historyResult.importedCount, - warnings: warnings - ) - } - - private static func importCookies( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> CookieImportResult { - switch browser.family { - case .firefox: - return await importFirefoxCookies( - from: browser, - sourceProfiles: sourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - case .chromium: - return await importChromiumCookies( - from: browser, - sourceProfiles: sourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - case .webkit: - if browser.descriptor.id == "safari" { - return CookieImportResult( - importedCount: 0, - skippedCount: 0, - warnings: [ - String( - localized: "browser.import.warning.safariCookiesUnsupported", - defaultValue: "Safari cookies are stored in Cookies.binarycookies and are not yet supported by this importer." - ) - ] - ) - } - return CookieImportResult( - importedCount: 0, - skippedCount: 0, - warnings: [ - String( - format: String( - localized: "browser.import.warning.cookieImportUnsupported", - defaultValue: "%@ cookie import is not implemented yet." - ), - browser.displayName - ) - ] - ) - } - } - - private static func importHistory( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> HistoryImportResult { - switch browser.family { - case .firefox: - return await importFirefoxHistory( - from: browser, - sourceProfiles: sourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - case .chromium: - return await importChromiumHistory( - from: browser, - sourceProfiles: sourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - case .webkit: - return await importWebKitHistory( - from: browser, - sourceProfiles: sourceProfiles, - destinationProfileID: destinationProfileID, - domainFilters: domainFilters - ) - } - } - - private static func importFirefoxCookies( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> CookieImportResult { - let fileManager = FileManager.default - var cookies: [HTTPCookie] = [] - var warnings: [String] = [] - - let databaseURLs = sourceProfiles.map { - $0.rootURL.appendingPathComponent("cookies.sqlite", isDirectory: false) - }.filter { fileManager.fileExists(atPath: $0.path) } - - for databaseURL in databaseURLs { - do { - try querySQLiteRows( - sourceDatabaseURL: databaseURL, - sql: "SELECT host, name, value, path, expiry, isSecure FROM moz_cookies" - ) { statement in - let host = sqliteColumnText(statement, index: 0) ?? "" - let name = sqliteColumnText(statement, index: 1) ?? "" - let value = sqliteColumnText(statement, index: 2) ?? "" - let path = sqliteColumnText(statement, index: 3) ?? "/" - let expiry = sqliteColumnInt64(statement, index: 4) - let isSecure = sqliteColumnInt64(statement, index: 5) != 0 - - guard !name.isEmpty else { return } - guard domainMatches(host: host, filters: domainFilters) else { return } - - var properties: [HTTPCookiePropertyKey: Any] = [ - .domain: host, - .path: path.isEmpty ? "/" : path, - .name: name, - .value: value, - ] - if isSecure { - properties[.secure] = "TRUE" - } - if expiry > 0 { - properties[.expires] = Date(timeIntervalSince1970: TimeInterval(expiry)) - } - if let cookie = HTTPCookie(properties: properties) { - cookies.append(cookie) - } - } - } catch { - warnings.append( - String( - format: String( - localized: "browser.import.warning.firefoxCookiesReadFailed", - defaultValue: "Failed reading Firefox cookies at %@: %@" - ), - databaseURL.lastPathComponent, - error.localizedDescription - ) - ) - } - } - - let dedupedCookies = dedupeCookies(cookies) - let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) - return CookieImportResult(importedCount: importedCount, skippedCount: max(0, dedupedCookies.count - importedCount), warnings: warnings) - } - - private static func importChromiumCookies( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> CookieImportResult { - let fileManager = FileManager.default - var cookies: [HTTPCookie] = [] - var warnings: [String] = [] - var skippedEncryptedCookies = 0 - let decryptor = ChromiumCookieDecryptor(browser: browser) - - let databaseURLs = sourceProfiles.map { - $0.rootURL.appendingPathComponent("Cookies", isDirectory: false) - }.filter { fileManager.fileExists(atPath: $0.path) } - - for databaseURL in databaseURLs { - do { - try querySQLiteRows( - sourceDatabaseURL: databaseURL, - sql: "SELECT host_key, name, value, path, expires_utc, is_secure, encrypted_value FROM cookies" - ) { statement in - let host = sqliteColumnText(statement, index: 0) ?? "" - let name = sqliteColumnText(statement, index: 1) ?? "" - let value = sqliteColumnText(statement, index: 2) ?? "" - let path = sqliteColumnText(statement, index: 3) ?? "/" - let expiresUTC = sqliteColumnInt64(statement, index: 4) - let isSecure = sqliteColumnInt64(statement, index: 5) != 0 - let encryptedValue = sqliteColumnData(statement, index: 6) - - guard !name.isEmpty else { return } - guard domainMatches(host: host, filters: domainFilters) else { return } - - var usableValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - if usableValue.isEmpty && !encryptedValue.isEmpty { - if let decryptedValue = decryptor.decryptCookieValue( - encryptedValue: encryptedValue, - host: host - ) { - usableValue = decryptedValue - } else { - skippedEncryptedCookies += 1 - return - } - } - - var properties: [HTTPCookiePropertyKey: Any] = [ - .domain: host, - .path: path.isEmpty ? "/" : path, - .name: name, - .value: usableValue, - ] - if isSecure { - properties[.secure] = "TRUE" - } - if let expiresDate = chromiumDate(fromWebKitMicroseconds: expiresUTC) { - properties[.expires] = expiresDate - } - if let cookie = HTTPCookie(properties: properties) { - cookies.append(cookie) - } - } - } catch { - warnings.append( - String( - format: String( - localized: "browser.import.warning.browserCookiesReadFailed", - defaultValue: "Failed reading %@ cookies at %@: %@" - ), - browser.displayName, - databaseURL.lastPathComponent, - error.localizedDescription - ) - ) - } - } - - let dedupedCookies = dedupeCookies(cookies) - let importedCount = await setCookiesInStore(dedupedCookies, destinationProfileID: destinationProfileID) - if let warning = decryptor.warningMessage( - browserName: browser.displayName, - skippedCount: skippedEncryptedCookies - ) { - warnings.append(warning) - } - let skippedCount = max(0, dedupedCookies.count - importedCount) + skippedEncryptedCookies - return CookieImportResult(importedCount: importedCount, skippedCount: skippedCount, warnings: warnings) - } - - private static func importFirefoxHistory( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> HistoryImportResult { - let fileManager = FileManager.default - var rows: [HistoryRow] = [] - var warnings: [String] = [] - - let databaseURLs = sourceProfiles.map { - $0.rootURL.appendingPathComponent("places.sqlite", isDirectory: false) - }.filter { fileManager.fileExists(atPath: $0.path) } - - for databaseURL in databaseURLs { - do { - try querySQLiteRows( - sourceDatabaseURL: databaseURL, - sql: """ - SELECT url, title, visit_count, last_visit_date - FROM moz_places - WHERE url LIKE 'http%' - ORDER BY last_visit_date DESC - LIMIT 5000 - """ - ) { statement in - let url = sqliteColumnText(statement, index: 0) ?? "" - let title = sqliteColumnText(statement, index: 1) - let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) - let lastVisitMicros = sqliteColumnInt64(statement, index: 3) - guard let parsedURL = URL(string: url), - let host = parsedURL.host, - domainMatches(host: host, filters: domainFilters) else { - return - } - let lastVisited = firefoxDate(fromUnixMicroseconds: lastVisitMicros) ?? .distantPast - rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) - } - } catch { - warnings.append( - String( - format: String( - localized: "browser.import.warning.firefoxHistoryReadFailed", - defaultValue: "Failed reading Firefox history at %@: %@" - ), - databaseURL.lastPathComponent, - error.localizedDescription - ) - ) - } - } - - let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) - return HistoryImportResult(importedCount: importedCount, warnings: warnings) - } - - private static func importChromiumHistory( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> HistoryImportResult { - let fileManager = FileManager.default - var rows: [HistoryRow] = [] - var warnings: [String] = [] - - let databaseURLs = sourceProfiles.map { - $0.rootURL.appendingPathComponent("History", isDirectory: false) - }.filter { fileManager.fileExists(atPath: $0.path) } - - for databaseURL in databaseURLs { - do { - try querySQLiteRows( - sourceDatabaseURL: databaseURL, - sql: """ - SELECT url, title, visit_count, last_visit_time - FROM urls - WHERE url LIKE 'http%' - ORDER BY last_visit_time DESC - LIMIT 5000 - """ - ) { statement in - let url = sqliteColumnText(statement, index: 0) ?? "" - let title = sqliteColumnText(statement, index: 1) - let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) - let lastVisitMicros = sqliteColumnInt64(statement, index: 3) - guard let parsedURL = URL(string: url), - let host = parsedURL.host, - domainMatches(host: host, filters: domainFilters) else { - return - } - let lastVisited = chromiumDate(fromWebKitMicroseconds: lastVisitMicros) ?? .distantPast - rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) - } - } catch { - warnings.append( - String( - format: String( - localized: "browser.import.warning.browserHistoryReadFailed", - defaultValue: "Failed reading %@ history at %@: %@" - ), - browser.displayName, - databaseURL.lastPathComponent, - error.localizedDescription - ) - ) - } - } - - let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) - return HistoryImportResult(importedCount: importedCount, warnings: warnings) - } - - private static func importWebKitHistory( - from browser: InstalledBrowserCandidate, - sourceProfiles: [InstalledBrowserProfile], - destinationProfileID: UUID, - domainFilters: [String] - ) async -> HistoryImportResult { - let fileManager = FileManager.default - var rows: [HistoryRow] = [] - var warnings: [String] = [] - - var candidateDatabaseURLs = sourceProfiles.map { - $0.rootURL.appendingPathComponent("History.db", isDirectory: false) - } - if browser.descriptor.id == "safari" { - candidateDatabaseURLs.append( - browser.homeDirectoryURL - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Safari", isDirectory: true) - .appendingPathComponent("History.db", isDirectory: false) - ) - } - let uniqueURLs = dedupedCanonicalURLs(candidateDatabaseURLs).filter { fileManager.fileExists(atPath: $0.path) } - - if uniqueURLs.isEmpty { - return HistoryImportResult( - importedCount: 0, - warnings: [ - String( - format: String( - localized: "browser.import.warning.noHistoryDatabase", - defaultValue: "No history database found for %@." - ), - browser.displayName - ) - ] - ) - } - - for databaseURL in uniqueURLs { - do { - try querySQLiteRows( - sourceDatabaseURL: databaseURL, - sql: """ - SELECT history_items.url, - history_items.title, - COUNT(history_visits.id) AS visit_count, - MAX(history_visits.visit_time) AS last_visit_time - FROM history_items - JOIN history_visits - ON history_items.id = history_visits.history_item - GROUP BY history_items.url - ORDER BY last_visit_time DESC - LIMIT 5000 - """ - ) { statement in - let url = sqliteColumnText(statement, index: 0) ?? "" - let title = sqliteColumnText(statement, index: 1) - let visitCount = max(1, Int(sqliteColumnInt64(statement, index: 2))) - let lastVisitReferenceSeconds = sqliteColumnDouble(statement, index: 3) - guard let parsedURL = URL(string: url), - let host = parsedURL.host, - domainMatches(host: host, filters: domainFilters) else { - return - } - let lastVisited = Date(timeIntervalSinceReferenceDate: lastVisitReferenceSeconds) - rows.append(HistoryRow(url: url, title: title, visitCount: visitCount, lastVisited: lastVisited)) - } - } catch { - warnings.append( - String( - format: String( - localized: "browser.import.warning.browserHistoryReadFailed", - defaultValue: "Failed reading %@ history at %@: %@" - ), - browser.displayName, - databaseURL.lastPathComponent, - error.localizedDescription - ) - ) - } - } - - let importedCount = await mergeHistoryRows(rows, destinationProfileID: destinationProfileID) - return HistoryImportResult(importedCount: importedCount, warnings: warnings) - } - - private static func mergeHistoryRows(_ rows: [HistoryRow], destinationProfileID: UUID) async -> Int { - guard !rows.isEmpty else { return 0 } - return await MainActor.run { - let entries = rows.compactMap { row -> BrowserHistoryStore.Entry? in - guard let parsedURL = URL(string: row.url), - let scheme = parsedURL.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - return nil - } - let trimmedTitle = row.title?.trimmingCharacters(in: .whitespacesAndNewlines) - return BrowserHistoryStore.Entry( - id: UUID(), - url: parsedURL.absoluteString, - title: trimmedTitle, - lastVisited: row.lastVisited, - visitCount: max(1, row.visitCount) - ) - } - let historyStore = BrowserProfileStore.shared.historyStore(for: destinationProfileID) - return historyStore.mergeImportedEntries(entries) - } - } - - private static func setCookiesInStore(_ cookies: [HTTPCookie], destinationProfileID: UUID) async -> Int { - guard !cookies.isEmpty else { return 0 } - let store = await MainActor.run { - BrowserProfileStore.shared.websiteDataStore(for: destinationProfileID).httpCookieStore - } - var importedCount = 0 - for cookie in cookies { - await setCookie(cookie, in: store) - importedCount += 1 - } - return importedCount - } - - @MainActor - private static func setCookie(_ cookie: HTTPCookie, in store: WKHTTPCookieStore) async { - await withCheckedContinuation { continuation in - store.setCookie(cookie) { - continuation.resume() - } - } - } - - private static func dedupeCookies(_ cookies: [HTTPCookie]) -> [HTTPCookie] { - var dedupedByKey: [String: HTTPCookie] = [:] - for cookie in cookies { - let key = "\(cookie.name.lowercased())|\(cookie.domain.lowercased())|\(cookie.path)" - if let existing = dedupedByKey[key] { - let existingExpiry = existing.expiresDate ?? .distantPast - let candidateExpiry = cookie.expiresDate ?? .distantPast - if candidateExpiry >= existingExpiry { - dedupedByKey[key] = cookie - } - } else { - dedupedByKey[key] = cookie - } - } - return Array(dedupedByKey.values) - } - - private static func canonicalDomain(_ raw: String) -> String? { - var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - while value.hasPrefix(".") { - value.removeFirst() - } - guard !value.isEmpty else { return nil } - guard let components = URLComponents(string: "https://\(value)"), - components.user == nil, - components.password == nil, - components.port == nil, - components.path.isEmpty, - components.query == nil, - components.fragment == nil, - let host = components.host else { - return value - } - return host.lowercased() - } - - static func domainMatches(host: String, filters: [String]) -> Bool { - if filters.isEmpty { return true } - guard let normalizedHost = canonicalDomain(host) else { return false } - for filter in filters { - guard let normalizedFilter = canonicalDomain(filter) else { continue } - if normalizedHost == normalizedFilter { return true } - if normalizedHost.hasSuffix(".\(normalizedFilter)") { return true } - } - return false - } - - private static func chromiumDate(fromWebKitMicroseconds rawValue: Int64) -> Date? { - guard rawValue > 0 else { return nil } - let unixSeconds = (Double(rawValue) / 1_000_000.0) - 11_644_473_600.0 - guard unixSeconds.isFinite else { return nil } - return Date(timeIntervalSince1970: unixSeconds) - } - - private static func firefoxDate(fromUnixMicroseconds rawValue: Int64) -> Date? { - guard rawValue > 0 else { return nil } - let seconds = Double(rawValue) / 1_000_000.0 - guard seconds.isFinite else { return nil } - return Date(timeIntervalSince1970: seconds) - } - - private static func querySQLiteRows( - sourceDatabaseURL: URL, - sql: String, - rowHandler: (OpaquePointer) throws -> Void - ) throws { - let fileManager = FileManager.default - let tempRoot = fileManager.temporaryDirectory.appendingPathComponent( - "cmux-browser-import-\(UUID().uuidString)", - isDirectory: true - ) - try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: tempRoot) } - - let snapshotURL = tempRoot.appendingPathComponent(sourceDatabaseURL.lastPathComponent, isDirectory: false) - try fileManager.copyItem(at: sourceDatabaseURL, to: snapshotURL) - - let walSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-wal") - let walSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-wal") - if fileManager.fileExists(atPath: walSourceURL.path) { - try? fileManager.copyItem(at: walSourceURL, to: walSnapshotURL) - } - let shmSourceURL = URL(fileURLWithPath: "\(sourceDatabaseURL.path)-shm") - let shmSnapshotURL = URL(fileURLWithPath: "\(snapshotURL.path)-shm") - if fileManager.fileExists(atPath: shmSourceURL.path) { - try? fileManager.copyItem(at: shmSourceURL, to: shmSnapshotURL) - } - - var database: OpaquePointer? - let openCode = sqlite3_open_v2(snapshotURL.path, &database, SQLITE_OPEN_READONLY, nil) - guard openCode == SQLITE_OK, let database else { - let message = sqliteMessage(from: database) ?? "unknown SQLite open failure" - sqlite3_close(database) - throw NSError(domain: "BrowserDataImporter", code: Int(openCode), userInfo: [ - NSLocalizedDescriptionKey: message, - ]) - } - defer { sqlite3_close(database) } - - var statement: OpaquePointer? - let prepareCode = sqlite3_prepare_v2(database, sql, -1, &statement, nil) - guard prepareCode == SQLITE_OK, let statement else { - let message = sqliteMessage(from: database) ?? "unknown SQLite prepare failure" - sqlite3_finalize(statement) - throw NSError(domain: "BrowserDataImporter", code: Int(prepareCode), userInfo: [ - NSLocalizedDescriptionKey: message, - ]) - } - defer { sqlite3_finalize(statement) } - - while true { - let stepCode = sqlite3_step(statement) - if stepCode == SQLITE_ROW { - try rowHandler(statement) - continue - } - if stepCode == SQLITE_DONE { - break - } - let message = sqliteMessage(from: database) ?? "unknown SQLite step failure" - throw NSError(domain: "BrowserDataImporter", code: Int(stepCode), userInfo: [ - NSLocalizedDescriptionKey: message, - ]) - } - } - - private static func sqliteMessage(from database: OpaquePointer?) -> String? { - guard let database, let cString = sqlite3_errmsg(database) else { return nil } - return String(cString: cString) - } - - private static func sqliteColumnText(_ statement: OpaquePointer, index: Int32) -> String? { - guard let cValue = sqlite3_column_text(statement, index) else { return nil } - return String(cString: cValue) - } - - private static func sqliteColumnInt64(_ statement: OpaquePointer, index: Int32) -> Int64 { - sqlite3_column_int64(statement, index) - } - - private static func sqliteColumnDouble(_ statement: OpaquePointer, index: Int32) -> Double { - sqlite3_column_double(statement, index) - } - - private static func sqliteColumnBytes(_ statement: OpaquePointer, index: Int32) -> Int { - Int(sqlite3_column_bytes(statement, index)) - } - - private static func sqliteColumnData(_ statement: OpaquePointer, index: Int32) -> Data { - let length = Int(sqlite3_column_bytes(statement, index)) - guard length > 0, let pointer = sqlite3_column_blob(statement, index) else { - return Data() - } - return Data(bytes: pointer, count: length) - } -} - -#if DEBUG -enum BrowserImportUITestFixtureLoader { - private struct BrowserFixture: Decodable { - let browserName: String - let profiles: [String] - } - - static func browsers(from environment: [String: String]) -> [InstalledBrowserCandidate]? { - guard let rawFixture = environment["PROGRAMA_UI_TEST_BROWSER_IMPORT_FIXTURE"], - let data = rawFixture.data(using: .utf8), - let fixture = try? JSONDecoder().decode(BrowserFixture.self, from: data) else { - return nil - } - - let resolvedProfiles = fixture.profiles.enumerated().map { index, name in - InstalledBrowserProfile( - displayName: name, - rootURL: FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-ui-test-browser-import") - .appendingPathComponent( - fixture.browserName - .lowercased() - .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) - ) - .appendingPathComponent("\(index)-\(name)") - .standardizedFileURL, - isDefault: index == 0 - ) - } - - let descriptor = InstalledBrowserDetector.allBrowserDescriptors.first(where: { - $0.displayName == fixture.browserName - }) ?? BrowserImportBrowserDescriptor( - id: fixture.browserName - .lowercased() - .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) - .trimmingCharacters(in: CharacterSet(charactersIn: "-")), - displayName: fixture.browserName, - family: .chromium, - tier: 0, - bundleIdentifiers: [], - appNames: [], - dataRootRelativePaths: [], - dataArtifactRelativePaths: [], - supportsDataOnlyDetection: false - ) - - return [ - InstalledBrowserCandidate( - descriptor: descriptor, - resolvedFamily: descriptor.family, - homeDirectoryURL: FileManager.default.homeDirectoryForCurrentUser, - appURL: nil, - dataRootURL: nil, - profiles: resolvedProfiles, - detectionSignals: ["ui-test-fixture"], - detectionScore: Int.max - ) - ] - } - - static func destinationProfiles(from environment: [String: String]) -> [BrowserProfileDefinition]? { - guard let rawDestinations = environment["PROGRAMA_UI_TEST_BROWSER_IMPORT_DESTINATIONS"], - let data = rawDestinations.data(using: .utf8), - let names = try? JSONDecoder().decode([String].self, from: data), - !names.isEmpty else { - return nil - } - - return names.enumerated().map { index, rawName in - let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) - if name.localizedCaseInsensitiveCompare("Default") == .orderedSame { - return BrowserProfileDefinition( - id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, - displayName: "Default", - createdAt: .distantPast, - isBuiltInDefault: true - ) - } - return BrowserProfileDefinition( - id: UUID(), - displayName: name.isEmpty ? "Profile \(index + 1)" : name, - createdAt: .distantPast, - isBuiltInDefault: false - ) - } - } -} -#endif - -@MainActor -final class BrowserDataImportCoordinator { - static let shared = BrowserDataImportCoordinator() - - private var importInProgress = false - - private init() {} - - func presentImportDialog(defaultDestinationProfileID: UUID? = nil) { - presentImportDialog(prefilledBrowsers: nil, defaultDestinationProfileID: defaultDestinationProfileID) - } - - struct ImportSelection { - let browser: InstalledBrowserCandidate - let executionPlan: BrowserImportExecutionPlan - let scope: BrowserImportScope - let domainFilters: [String] - } - - private func presentImportDialog( - prefilledBrowsers: [InstalledBrowserCandidate]?, - defaultDestinationProfileID: UUID? - ) { - guard !importInProgress else { return } -#if DEBUG - let environment = ProcessInfo.processInfo.environment - let fixtureBrowsers = BrowserImportUITestFixtureLoader.browsers(from: environment) - let fixtureDestinationProfiles = BrowserImportUITestFixtureLoader.destinationProfiles(from: environment) - let browsers = prefilledBrowsers ?? fixtureBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() -#else - let fixtureDestinationProfiles: [BrowserProfileDefinition]? = nil - let browsers = prefilledBrowsers ?? InstalledBrowserDetector.detectInstalledBrowsers() -#endif - guard !browsers.isEmpty else { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String( - localized: "browser.import.noBrowsers.title", - defaultValue: "No importable browsers found" - ) - alert.informativeText = String( - localized: "browser.import.noBrowsers.message", - defaultValue: "Programa could not find browser profiles to import from on this Mac." - ) - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.runModal() - return - } - - guard let selection = promptForSelection( - browsers: browsers, - destinationProfiles: fixtureDestinationProfiles, - defaultDestinationProfileID: defaultDestinationProfileID - ) else { return } - -#if DEBUG - if captureSelectionIfRequested(selection, destinationProfiles: fixtureDestinationProfiles) { - return - } -#endif - let realizedPlan: RealizedBrowserImportExecutionPlan - do { - realizedPlan = try BrowserImportPlanResolver.realize(plan: selection.executionPlan) - } catch { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String( - localized: "browser.import.error.title", - defaultValue: "Import could not start" - ) - alert.informativeText = error.localizedDescription - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.runModal() - return - } - importInProgress = true - - let progressWindow = showProgressWindow( - title: String( - localized: "browser.import.progress.title", - defaultValue: "Importing Browser Data" - ), - message: String( - format: String( - localized: "browser.import.progress.message", - defaultValue: "Importing %@ from %@…" - ), - selection.scope.displayName.lowercased(), - selection.browser.displayName - ) - ) - - Task.detached(priority: .userInitiated) { - let outcome = await BrowserDataImporter.importData( - from: selection.browser, - plan: realizedPlan, - scope: selection.scope, - domainFilters: selection.domainFilters - ) - - await MainActor.run { - self.hideProgressWindow(progressWindow) - self.presentOutcome(outcome) - self.importInProgress = false - } - } - } - - private func promptForSelection( - browsers: [InstalledBrowserCandidate], - destinationProfiles: [BrowserProfileDefinition]?, - defaultDestinationProfileID: UUID? - ) -> ImportSelection? { - guard !browsers.isEmpty else { return nil } - let wizard = BrowserImportWizardWindowController( - browsers: browsers, - destinationProfiles: destinationProfiles, - defaultDestinationProfileID: defaultDestinationProfileID - ) - return wizard.runModal() - } - -#if DEBUG - func debugMakeImportWizardWindow( - browsers: [InstalledBrowserCandidate], - destinationProfiles: [BrowserProfileDefinition]? = nil, - defaultDestinationProfileID: UUID? = nil - ) -> NSWindow { - let wizard = BrowserImportWizardWindowController( - browsers: browsers, - destinationProfiles: destinationProfiles, - defaultDestinationProfileID: defaultDestinationProfileID - ) - return wizard.debugPanelWindow - } -#endif - -#if DEBUG - private struct CapturedImportSelection: Encodable { - struct Entry: Encodable { - let sourceProfiles: [String] - let destinationKind: String - let destinationName: String - } - - let browserName: String - let mode: String - let scope: String - let domainFilters: [String] - let entries: [Entry] - } - - private func captureSelectionIfRequested( - _ selection: ImportSelection, - destinationProfiles: [BrowserProfileDefinition]? - ) -> Bool { - let environment = ProcessInfo.processInfo.environment - guard environment["PROGRAMA_UI_TEST_BROWSER_IMPORT_MODE"] == "capture-only" else { return false } - guard let path = environment["PROGRAMA_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"], !path.isEmpty else { - return true - } - - let availableDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles - let payload = CapturedImportSelection( - browserName: selection.browser.displayName, - mode: captureModeName(selection.executionPlan.mode), - scope: selection.scope.rawValue, - domainFilters: selection.domainFilters, - entries: selection.executionPlan.entries.map { entry in - let destinationKind: String - let destinationName: String - switch entry.destination { - case .existing(let id): - destinationKind = "existing" - destinationName = availableDestinationProfiles.first(where: { $0.id == id })?.displayName - ?? BrowserProfileStore.shared.displayName(for: id) - case .createNamed(let name): - destinationKind = "create" - destinationName = name - } - return CapturedImportSelection.Entry( - sourceProfiles: entry.sourceProfiles.map(\.displayName), - destinationKind: destinationKind, - destinationName: destinationName - ) - } - ) - - guard let data = try? JSONEncoder().encode(payload) else { return true } - let url = URL(fileURLWithPath: path) - try? FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: nil - ) - try? data.write(to: url) - return true - } - - private func captureModeName(_ mode: BrowserImportDestinationMode) -> String { - switch mode { - case .singleDestination: - return "singleDestination" - case .separateProfiles: - return "separateProfiles" - case .mergeIntoOne: - return "mergeIntoOne" - } - } -#endif - - private func showProgressWindow(title: String, message: String) -> NSWindow { - let window = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 122), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - window.title = title - window.isReleasedWhenClosed = false - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - - let content = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 122)) - - let spinner = NSProgressIndicator(frame: NSRect(x: 20, y: 50, width: 20, height: 20)) - spinner.style = .spinning - spinner.controlSize = .regular - spinner.startAnimation(nil) - content.addSubview(spinner) - - let titleLabel = NSTextField(labelWithString: message) - titleLabel.frame = NSRect(x: 52, y: 56, width: 340, height: 20) - titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium) - content.addSubview(titleLabel) - - let subtitleLabel = NSTextField( - labelWithString: String( - localized: "browser.import.progress.subtitle", - defaultValue: "This can take a few seconds for large profiles." - ) - ) - subtitleLabel.frame = NSRect(x: 52, y: 34, width: 340, height: 16) - subtitleLabel.font = NSFont.systemFont(ofSize: 11) - subtitleLabel.textColor = .secondaryLabelColor - content.addSubview(subtitleLabel) - - window.contentView = content - - if let keyWindow = NSApp.keyWindow { - keyWindow.beginSheet(window, completionHandler: nil) - } else { - window.center() - window.makeKeyAndOrderFront(nil) - } - - return window - } - - private func hideProgressWindow(_ window: NSWindow) { - if let parent = window.sheetParent { - parent.endSheet(window) - } else { - window.orderOut(nil) - } - } - - private func presentOutcome(_ outcome: BrowserImportOutcome) { - let lines = BrowserImportOutcomeFormatter.lines(for: outcome) - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = String( - localized: "browser.import.complete.title", - defaultValue: "Browser data import complete" - ) - alert.informativeText = lines.joined(separator: "\n") - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - alert.runModal() - } -} - -// MARK: - User Proxy Settings - -/// Persists and reads user-configured proxy settings (config-file only; no Settings UI). -/// -/// When `browser.proxy` is present in `settings.json`, the config-file parser writes -/// host/port/type into UserDefaults via these keys. `BrowserPanel` reads them back -/// through `descriptor()` when building `WKWebsiteDataStore.proxyConfigurations`. diff --git a/Sources/Panels/BrowserExtensionAdapters.swift b/Sources/Panels/BrowserExtensionAdapters.swift deleted file mode 100644 index de8b7773..00000000 --- a/Sources/Panels/BrowserExtensionAdapters.swift +++ /dev/null @@ -1,119 +0,0 @@ -import AppKit -import Bonsplit -import Foundation -import WebKit - -/// Adapters that expose Programa's browser panels to web extensions as windows and tabs. -/// -/// Extensions drive dynamic script injection through `tabs.query` + `scripting.executeScript` -/// (1Password's autofill works exactly this way), and both APIs resolve against the objects -/// registered here. Without these adapters an extension sees a browser with zero tabs and -/// can inject nothing. -/// -/// Mapping (deliberately simple for this slice): -/// - One `WKWebExtensionWindow` representing the app — every live `BrowserPanel` across all -/// workspaces is a tab of it. Programa's real model (workspaces → panes → tabs mixing -/// terminals and browsers) has no clean analog in the extension world; a single flat -/// window is honest enough for tab targeting, which is what injection needs. -/// - The "active" tab is the browser panel the user most recently selected/focused. -/// - Popup windows (`BrowserPopupWindowController`) are not represented yet; their content -/// scripts still run (shared configuration), but tab-targeted APIs skip them. -/// -/// Both WebKit protocols are fully `@optional`. CAUTION: an implementation whose Swift -/// signature does not exactly match the protocol's is silently ignored — no compiler error, -/// the method just never gets called. Watch for "nearly matches optional requirement" -/// warnings when touching this file, and re-run the tabs-poc probe (see PR #284) after. -@available(macOS 15.4, *) -@MainActor -final class BrowserExtensionTabAdapter: NSObject, WKWebExtensionTab { - private(set) weak var panel: BrowserPanel? - - init(panel: BrowserPanel) { - self.panel = panel - super.init() - } - - nonisolated func webView(for context: WKWebExtensionContext) -> WKWebView? { - MainActor.assumeIsolated { panel?.webView } - } - - nonisolated func url(for context: WKWebExtensionContext) -> URL? { - MainActor.assumeIsolated { panel?.webView.url } - } - - nonisolated func title(for context: WKWebExtensionContext) -> String? { - MainActor.assumeIsolated { panel?.webView.title } - } - - nonisolated func isLoadingComplete(for context: WKWebExtensionContext) -> Bool { - MainActor.assumeIsolated { !(panel?.webView.isLoading ?? true) } - } - - nonisolated func window(for context: WKWebExtensionContext) -> (any WKWebExtensionWindow)? { - MainActor.assumeIsolated { BrowserExtensionManager.shared.windowAdapter } - } - - nonisolated func indexInWindow(for context: WKWebExtensionContext) -> Int { - MainActor.assumeIsolated { - BrowserExtensionManager.shared.tabAdapters.firstIndex(where: { $0 === self }) ?? 0 - } - } - - nonisolated func isSelected(for context: WKWebExtensionContext) -> Bool { - MainActor.assumeIsolated { BrowserExtensionManager.shared.activeTabAdapter === self } - } -} - -/// The single app-level window presented to extensions; tabs are all live browser panels. -@available(macOS 15.4, *) -@MainActor -final class BrowserExtensionWindowAdapter: NSObject, WKWebExtensionWindow { - - nonisolated func tabs(for context: WKWebExtensionContext) -> [any WKWebExtensionTab] { - MainActor.assumeIsolated { BrowserExtensionManager.shared.tabAdapters } - } - - nonisolated func activeTab(for context: WKWebExtensionContext) -> (any WKWebExtensionTab)? { - MainActor.assumeIsolated { BrowserExtensionManager.shared.activeTabAdapter } - } - - nonisolated func windowType(for context: WKWebExtensionContext) -> WKWebExtension.WindowType { - .normal - } - - nonisolated func windowState(for context: WKWebExtensionContext) -> WKWebExtension.WindowState { - .normal - } - - nonisolated func isPrivate(for context: WKWebExtensionContext) -> Bool { - false - } - - nonisolated func frame(for context: WKWebExtensionContext) -> CGRect { - MainActor.assumeIsolated { NSApp.mainWindow?.frame ?? .zero } - } - - nonisolated func screenFrame(for context: WKWebExtensionContext) -> CGRect { - MainActor.assumeIsolated { NSApp.mainWindow?.screen?.frame ?? NSScreen.main?.frame ?? .zero } - } -} - -/// Answers the controller's world-state queries. The delegate's `openWindowsFor` reply is -/// what initially populates each extension context's `openWindows`/`openTabs` — the -/// incremental `didOpenTab`/`didActivateTab` notifications only build on that baseline. -@available(macOS 15.4, *) -final class BrowserExtensionControllerDelegate: NSObject, WKWebExtensionControllerDelegate { - func webExtensionController( - _ controller: WKWebExtensionController, - openWindowsFor extensionContext: WKWebExtensionContext - ) -> [any WKWebExtensionWindow] { - MainActor.assumeIsolated { [BrowserExtensionManager.shared.windowAdapter] } - } - - func webExtensionController( - _ controller: WKWebExtensionController, - focusedWindowFor extensionContext: WKWebExtensionContext - ) -> (any WKWebExtensionWindow)? { - MainActor.assumeIsolated { BrowserExtensionManager.shared.windowAdapter } - } -} diff --git a/Sources/Panels/BrowserExtensionManager.swift b/Sources/Panels/BrowserExtensionManager.swift deleted file mode 100644 index edf2b155..00000000 --- a/Sources/Panels/BrowserExtensionManager.swift +++ /dev/null @@ -1,318 +0,0 @@ -import AppKit -import Bonsplit -import CryptoKit -import Foundation -import WebKit - -enum BrowserExtensionConsentDecision: String, Codable, Equatable { - case approved - case denied -} - -/// Persists authority for one exact extension manifest. A decision only applies when both -/// the stable candidate identity and the complete authority fingerprint still match. -struct BrowserExtensionConsentStore { - struct Record: Codable, Equatable { - let fingerprint: String - let decision: BrowserExtensionConsentDecision - } - - private static let defaultsKey = "browser.webExtensions.consent.v1" - private let defaults: UserDefaults - private let defaultsKey: String - - init(defaults: UserDefaults = .standard, defaultsKey: String = Self.defaultsKey) { - self.defaults = defaults - self.defaultsKey = defaultsKey - } - - func decision(candidateID: String, fingerprint: String) -> BrowserExtensionConsentDecision? { - guard let record = records()[candidateID], record.fingerprint == fingerprint else { return nil } - return record.decision - } - - func setDecision(_ decision: BrowserExtensionConsentDecision, candidateID: String, fingerprint: String) { - var current = records() - current[candidateID] = Record(fingerprint: fingerprint, decision: decision) - guard let data = try? JSONEncoder().encode(current) else { return } - defaults.set(data, forKey: defaultsKey) - } - - static func fingerprint( - candidateID: String, - version: String?, - permissions: [String], - hostPatterns: [String] - ) -> String { - let canonical = ([candidateID, version ?? ""] + permissions.sorted() + ["\u{0}"] + hostPatterns.sorted()) - .joined(separator: "\u{1F}") - return SHA256.hash(data: Data(canonical.utf8)).map { String(format: "%02x", $0) }.joined() - } - - private func records() -> [String: Record] { - guard - let data = defaults.data(forKey: defaultsKey), - let decoded = try? JSONDecoder().decode([String: Record].self, from: data) - else { return [:] } - return decoded - } -} - -/// Loads explicitly approved unpacked Web Extensions from -/// `~/.config/programa/extensions/` on macOS 15.4 and later. -@available(macOS 15.4, *) -@MainActor -final class BrowserExtensionManager { - static let shared = BrowserExtensionManager() - - /// Fixed so extension storage (`chrome.storage`, IndexedDB, service-worker state) - /// lands in the same WebKit container every launch. Changing it orphans that data. - private static let controllerIdentifier = UUID(uuidString: "8B1F4F62-3A6C-4E5D-9D5B-2F0C7A9E41D3")! - - @MainActor - private struct Candidate { - let id: String - let sourceURL: URL - let extensionObject: WKWebExtension - let context: WKWebExtensionContext - let fingerprint: String - - var name: String { extensionObject.displayName ?? sourceURL.lastPathComponent } - var version: String { extensionObject.displayVersion ?? extensionObject.version ?? "?" } - var permissions: [String] { extensionObject.requestedPermissions.map(\.rawValue).sorted() } - var hostPatterns: [String] { extensionObject.allRequestedMatchPatterns.map(\.string).sorted() } - } - - static var extensionsDirectoryURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/programa/extensions", isDirectory: true) - } - - let controller: WKWebExtensionController - - private(set) var loadedExtensions: [WKWebExtension] = [] - private(set) var loadErrors: [(candidate: String, error: any Error)] = [] - private var candidates: [Candidate] = [] - private var loadedContexts: [String: WKWebExtensionContext] = [:] - private var loadingTask: Task<Void, Never>? - private let consentStore: BrowserExtensionConsentStore - - let windowAdapter = BrowserExtensionWindowAdapter() - private(set) var tabAdapters: [BrowserExtensionTabAdapter] = [] - private(set) var activeTabAdapter: BrowserExtensionTabAdapter? - private var announcedWindow = false - - private let controllerDelegate = BrowserExtensionControllerDelegate() - - private init(consentStore: BrowserExtensionConsentStore = BrowserExtensionConsentStore()) { - self.consentStore = consentStore - controller = WKWebExtensionController( - configuration: WKWebExtensionController.Configuration(identifier: Self.controllerIdentifier) - ) - controller.delegate = controllerDelegate - } - - func registerTab(for panel: BrowserPanel) { - guard tabAdapter(for: panel) == nil else { return } - if !announcedWindow { - announcedWindow = true - controller.didOpenWindow(windowAdapter) - controller.didFocusWindow(windowAdapter) - } - let adapter = BrowserExtensionTabAdapter(panel: panel) - tabAdapters.append(adapter) - controller.didOpenTab(adapter) - #if DEBUG - dlog("browser.extensions.tab.open panel=\(panel.id.uuidString.prefix(5)) total=\(tabAdapters.count)") - #endif - } - - func unregisterTab(for panel: BrowserPanel) { - guard let index = tabAdapters.firstIndex(where: { $0.panel === panel }) else { return } - let adapter = tabAdapters.remove(at: index) - if activeTabAdapter === adapter { activeTabAdapter = nil } - controller.didCloseTab(adapter, windowIsClosing: false) - } - - func noteTabActivated(_ panel: BrowserPanel) { - pruneDeadTabs() - registerTab(for: panel) - guard let adapter = tabAdapter(for: panel), activeTabAdapter !== adapter else { return } - let previous = activeTabAdapter - activeTabAdapter = adapter - controller.didActivateTab(adapter, previousActiveTab: previous) - controller.didSelectTabs([adapter]) - } - - private func tabAdapter(for panel: BrowserPanel) -> BrowserExtensionTabAdapter? { - tabAdapters.first(where: { $0.panel === panel }) - } - - func pruneDeadTabs() { - for adapter in tabAdapters where adapter.panel == nil { - tabAdapters.removeAll { $0 === adapter } - if activeTabAdapter === adapter { activeTabAdapter = nil } - controller.didCloseTab(adapter, windowIsClosing: false) - } - } - - func loadInstalledExtensionsIfNeeded() { - _ = beginLoadingIfNeeded() - } - - func presentManagementUI() { - Task { - await beginLoadingIfNeeded().value - presentCandidatePicker() - } - } - - private func beginLoadingIfNeeded() -> Task<Void, Never> { - if let loadingTask { return loadingTask } - let task = Task { await scanAndLoadApprovedExtensions() } - loadingTask = task - return task - } - - private func scanAndLoadApprovedExtensions() async { - let directory = Self.extensionsDirectoryURL - let entries = (try? FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - )) ?? [] - let sourceURLs = entries.filter { url in - if (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { return true } - return url.pathExtension.lowercased() == "zip" - }.sorted { $0.lastPathComponent < $1.lastPathComponent } - - for sourceURL in sourceURLs { - do { - let extensionObject = try await WKWebExtension(resourceBaseURL: sourceURL) - let candidate = makeCandidate(sourceURL: sourceURL, extensionObject: extensionObject) - candidates.append(candidate) - switch consentStore.decision(candidateID: candidate.id, fingerprint: candidate.fingerprint) { - case .approved: - try load(candidate) - case .denied: - break - case nil: - let approved = presentConsent(for: candidate) - consentStore.setDecision( - approved ? .approved : .denied, - candidateID: candidate.id, - fingerprint: candidate.fingerprint - ) - if approved { try load(candidate) } - } - } catch { - loadErrors.append((candidate: sourceURL.lastPathComponent, error: error)) - } - } - } - - private func makeCandidate(sourceURL: URL, extensionObject: WKWebExtension) -> Candidate { - let id = sourceURL.standardizedFileURL.path - let permissions = extensionObject.requestedPermissions.map(\.rawValue).sorted() - let hosts = extensionObject.allRequestedMatchPatterns.map(\.string).sorted() - return Candidate( - id: id, - sourceURL: sourceURL, - extensionObject: extensionObject, - context: WKWebExtensionContext(for: extensionObject), - fingerprint: BrowserExtensionConsentStore.fingerprint( - candidateID: id, - version: extensionObject.version, - permissions: permissions, - hostPatterns: hosts - ) - ) - } - - private func load(_ candidate: Candidate) throws { - guard loadedContexts[candidate.id] == nil else { return } - let never = Date.distantFuture - candidate.context.grantedPermissions = Dictionary( - uniqueKeysWithValues: candidate.extensionObject.requestedPermissions.map { ($0, never) } - ) - candidate.context.grantedPermissionMatchPatterns = Dictionary( - uniqueKeysWithValues: candidate.extensionObject.allRequestedMatchPatterns.map { ($0, never) } - ) - try controller.load(candidate.context) - loadedContexts[candidate.id] = candidate.context - loadedExtensions.append(candidate.extensionObject) - } - - private func revoke(_ candidate: Candidate) { - guard let context = loadedContexts.removeValue(forKey: candidate.id) else { return } - do { - try controller.unload(context) - } catch { - loadErrors.append((candidate: candidate.sourceURL.lastPathComponent, error: error)) - } - context.grantedPermissions = [:] - context.grantedPermissionMatchPatterns = [:] - loadedExtensions.removeAll { $0 === candidate.extensionObject } - consentStore.setDecision(.denied, candidateID: candidate.id, fingerprint: candidate.fingerprint) - } - - private func presentCandidatePicker() { - guard !candidates.isEmpty else { - let alert = NSAlert() - alert.messageText = String(localized: "browser.extensions.none.title", defaultValue: "No Browser Extensions") - alert.informativeText = String( - localized: "browser.extensions.none.message", - defaultValue: "Add an unpacked extension or ZIP archive to ~/.config/programa/extensions/." - ) - alert.runModal() - return - } - - let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 28)) - for candidate in candidates { - let state = loadedContexts[candidate.id] == nil - ? String(localized: "browser.extensions.disabled", defaultValue: "Disabled") - : String(localized: "browser.extensions.enabled", defaultValue: "Enabled") - popup.addItem(withTitle: "\(candidate.name) \(candidate.version) — \(state)") - } - let alert = NSAlert() - alert.messageText = String(localized: "browser.extensions.manage.title", defaultValue: "Manage Browser Extensions") - alert.informativeText = String( - localized: "browser.extensions.manage.message", - defaultValue: "Select an extension to enable it or revoke its access." - ) - alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "browser.extensions.change", defaultValue: "Change Access")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - guard alert.runModal() == .alertFirstButtonReturn else { return } - - let candidate = candidates[popup.indexOfSelectedItem] - if loadedContexts[candidate.id] != nil { - revoke(candidate) - } else if presentConsent(for: candidate) { - consentStore.setDecision(.approved, candidateID: candidate.id, fingerprint: candidate.fingerprint) - do { try load(candidate) } catch { - loadErrors.append((candidate: candidate.sourceURL.lastPathComponent, error: error)) - } - } - } - - private func presentConsent(for candidate: Candidate) -> Bool { - let none = String(localized: "browser.extensions.noneRequested", defaultValue: "None") - let permissions = candidate.permissions.isEmpty ? none : candidate.permissions.joined(separator: "\n• ") - let hosts = candidate.hostPatterns.isEmpty ? none : candidate.hostPatterns.joined(separator: "\n• ") - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String( - localized: "browser.extensions.consent.title", - defaultValue: "Enable \(candidate.name)?" - ) - alert.informativeText = String( - localized: "browser.extensions.consent.message", - defaultValue: "Version: \(candidate.version)\n\nPermissions:\n• \(permissions)\n\nWebsite access:\n• \(hosts)" - ) - alert.addButton(withTitle: String(localized: "browser.extensions.enable", defaultValue: "Enable")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - return alert.runModal() == .alertFirstButtonReturn - } -} diff --git a/Sources/Panels/BrowserImportWizardView.swift b/Sources/Panels/BrowserImportWizardView.swift deleted file mode 100644 index d2e02a46..00000000 --- a/Sources/Panels/BrowserImportWizardView.swift +++ /dev/null @@ -1,764 +0,0 @@ -// SwiftUI replacement for the hand-rolled AppKit `ImportWizardWindowController` -// (nuclear-review audit finding N3). The 3-step flow, validation rules, and -// resolver/importer call contract are preserved exactly; only the widget -// layer (NSStackView/NSPopUpButton/NSButton) changed to SwiftUI. -// -// Frozen call contract (must not change): BrowserImportPlanResolver.defaultPlan, -// BrowserImportPlanResolver.separateProfilesPlan, BrowserImportScope.fromSelection, -// BrowserDataImporter.parseDomainFilters, InstalledBrowserDetector.summaryText, -// BrowserProfileStore.shared (profiles/effectiveLastUsedProfileID/displayName(for:)). -// BrowserImportPlanResolver.realize(plan:) and BrowserDataImporter.importData(...) -// are invoked by BrowserDataImportCoordinator after this wizard returns a selection -// and are unaffected by this file. - -import AppKit -import SwiftUI - -// MARK: - View Model - -@MainActor -final class BrowserImportWizardViewModel: ObservableObject { - enum Step { - case source - case sourceProfiles - case dataTypes - } - - let browsers: [InstalledBrowserCandidate] - let destinationProfiles: [BrowserProfileDefinition] - let initialDestinationProfileID: UUID - - @Published private(set) var step: Step = .source - @Published var selectedBrowserIndex: Int = 0 { - didSet { validationMessage = nil } - } - @Published var destinationMode: BrowserImportDestinationMode = .singleDestination - @Published var separateExecutionEntries: [BrowserImportExecutionEntry] = [] - @Published var mergeDestinationProfileID: UUID - @Published var includeCookies = true - @Published var includeHistory = true - @Published var includeAdditionalData = false - @Published var domainFilterText = "" - @Published var validationMessage: String? - - private var selectedSourceProfileIDsByBrowserID: [String: Set<String>] = [:] - - private(set) var selection: BrowserDataImportCoordinator.ImportSelection? - var onFinish: ((NSApplication.ModalResponse) -> Void)? - - init( - browsers: [InstalledBrowserCandidate], - destinationProfiles: [BrowserProfileDefinition]?, - defaultDestinationProfileID: UUID? - ) { - let resolvedDestinationProfiles = destinationProfiles ?? BrowserProfileStore.shared.profiles - let fallbackDestinationProfileID = resolvedDestinationProfiles.first?.id - ?? BrowserProfileStore.shared.effectiveLastUsedProfileID - self.browsers = browsers - self.destinationProfiles = resolvedDestinationProfiles - self.initialDestinationProfileID = defaultDestinationProfileID - .flatMap { candidateID in resolvedDestinationProfiles.first(where: { $0.id == candidateID })?.id } - ?? fallbackDestinationProfileID - self.mergeDestinationProfileID = self.initialDestinationProfileID - } - - // MARK: Derived state - - func selectedBrowser() -> InstalledBrowserCandidate { - let index = max(0, min(selectedBrowserIndex, browsers.count - 1)) - return browsers[index] - } - - func selectedSourceProfiles() -> [InstalledBrowserProfile] { - let browser = selectedBrowser() - let selectedIDs = storedSelectedSourceProfileIDs(for: browser) - return browser.profiles.filter { selectedIDs.contains($0.id) } - } - - func isSourceProfileSelected(_ profile: InstalledBrowserProfile) -> Bool { - storedSelectedSourceProfileIDs(for: selectedBrowser()).contains(profile.id) - } - - func toggleSourceProfile(_ profile: InstalledBrowserProfile, isOn: Bool) { - let browser = selectedBrowser() - var selectedIDs = storedSelectedSourceProfileIDs(for: browser) - if isOn { - selectedIDs.insert(profile.id) - } else { - selectedIDs.remove(profile.id) - } - selectedSourceProfileIDsByBrowserID[browser.id] = selectedIDs - validationMessage = nil - } - - private func storedSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set<String> { - if let existing = selectedSourceProfileIDsByBrowserID[browser.id] { - return existing - } - let defaultSelection = defaultSelectedSourceProfileIDs(for: browser) - selectedSourceProfileIDsByBrowserID[browser.id] = defaultSelection - return defaultSelection - } - - private func defaultSelectedSourceProfileIDs(for browser: InstalledBrowserCandidate) -> Set<String> { - if let defaultProfile = browser.profiles.first(where: \.isDefault) { - return [defaultProfile.id] - } - if let firstProfile = browser.profiles.first { - return [firstProfile.id] - } - return [] - } - - var sourceProfilesPresentation: BrowserImportSourceProfilesPresentation { - BrowserImportSourceProfilesPresentation(profileCount: selectedBrowser().profiles.count) - } - - var step3Presentation: BrowserImportStep3Presentation { - BrowserImportStep3Presentation(plan: currentExecutionPlan()) - } - - func currentExecutionPlan() -> BrowserImportExecutionPlan { - let selectedProfiles = selectedSourceProfiles() - guard !selectedProfiles.isEmpty else { - return BrowserImportExecutionPlan(mode: .singleDestination, entries: []) - } - - guard selectedProfiles.count > 1 else { - return BrowserImportExecutionPlan( - mode: .singleDestination, - entries: [ - BrowserImportExecutionEntry( - sourceProfiles: selectedProfiles, - destination: .existing(resolvedMergeDestinationProfileID) - ) - ] - ) - } - - switch destinationMode { - case .separateProfiles: - let entriesBySourceID = Dictionary( - uniqueKeysWithValues: separateExecutionEntries.compactMap { entry in - entry.sourceProfiles.first.map { ($0.id, entry.destination) } - } - ) - let entries = selectedProfiles.map { profile in - BrowserImportExecutionEntry( - sourceProfiles: [profile], - destination: entriesBySourceID[profile.id] ?? defaultSeparateDestinationRequest(for: profile) - ) - } - return BrowserImportExecutionPlan(mode: .separateProfiles, entries: entries) - case .singleDestination, .mergeIntoOne: - return BrowserImportExecutionPlan( - mode: .mergeIntoOne, - entries: [ - BrowserImportExecutionEntry( - sourceProfiles: selectedProfiles, - destination: .existing(resolvedMergeDestinationProfileID) - ) - ] - ) - } - } - - func destinationOptions( - for entry: BrowserImportExecutionEntry, - sourceProfile: InstalledBrowserProfile - ) -> [BrowserImportDestinationRequest] { - var options = destinationProfiles.map { BrowserImportDestinationRequest.existing($0.id) } - let createName: String - switch entry.destination { - case .createNamed(let name): - createName = name - case .existing: - createName = sourceProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) - } - if !createName.isEmpty, - !destinationProfiles.contains(where: { - $0.displayName.trimmingCharacters(in: .whitespacesAndNewlines) - .localizedCaseInsensitiveCompare(createName) == .orderedSame - }) { - options.append(.createNamed(createName)) - } - return options - } - - func title(for request: BrowserImportDestinationRequest) -> String { - switch request { - case .existing(let id): - return destinationProfiles.first(where: { $0.id == id })?.displayName - ?? BrowserProfileStore.shared.displayName(for: id) - case .createNamed(let name): - return String( - format: String( - localized: "browser.import.destinationProfile.create", - defaultValue: "Create \"%@\"" - ), - name - ) - } - } - - func accessibilitySlug(for profile: InstalledBrowserProfile, index: Int) -> String { - let base = profile.displayName - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) - .trimmingCharacters(in: CharacterSet(charactersIn: "-")) - return base.isEmpty ? "profile-\(index)" : base - } - - func setSeparateDestination(at index: Int, to destination: BrowserImportDestinationRequest) { - guard separateExecutionEntries.indices.contains(index) else { return } - separateExecutionEntries[index].destination = destination - validationMessage = nil - } - - func setMergeDestination(profileID: UUID) { - guard destinationProfiles.contains(where: { $0.id == profileID }) else { return } - mergeDestinationProfileID = profileID - validationMessage = nil - } - - func setDestinationMode(_ mode: BrowserImportDestinationMode) { - guard selectedSourceProfiles().count > 1 else { return } - destinationMode = mode - } - - private func destinationProfileID(for entry: BrowserImportExecutionEntry) -> UUID? { - guard case .existing(let id) = entry.destination else { return nil } - return id - } - - var resolvedMergeDestinationProfileID: UUID { - if destinationProfiles.contains(where: { $0.id == mergeDestinationProfileID }) { - return mergeDestinationProfileID - } - return initialDestinationProfileID - } - - private func defaultSeparateDestinationRequest( - for profile: InstalledBrowserProfile - ) -> BrowserImportDestinationRequest { - BrowserImportPlanResolver.separateProfilesPlan( - selectedSourceProfiles: [profile], - destinationProfiles: destinationProfiles - ).entries.first?.destination ?? .createNamed(profile.displayName) - } - - private func resetStep3State() { - let selectedProfiles = selectedSourceProfiles() - let defaultPlan = BrowserImportPlanResolver.defaultPlan( - selectedSourceProfiles: selectedProfiles, - destinationProfiles: destinationProfiles, - preferredSingleDestinationProfileID: initialDestinationProfileID - ) - destinationMode = defaultPlan.mode - separateExecutionEntries = BrowserImportPlanResolver.separateProfilesPlan( - selectedSourceProfiles: selectedProfiles, - destinationProfiles: destinationProfiles - ).entries - if let initialDestination = defaultPlan.entries.first.flatMap(destinationProfileID(for:)) { - mergeDestinationProfileID = initialDestination - } else { - mergeDestinationProfileID = initialDestinationProfileID - } - } - - // MARK: Actions - - func handleBack() { - switch step { - case .source: - return - case .sourceProfiles: - step = .source - case .dataTypes: - step = .sourceProfiles - } - validationMessage = nil - } - - func handleCancel() { - onFinish?(.cancel) - } - - func handlePrimary() { - switch step { - case .source: - step = .sourceProfiles - validationMessage = nil - case .sourceProfiles: - guard !selectedSourceProfiles().isEmpty else { - validationMessage = String( - localized: "browser.import.validation.sourceProfiles", - defaultValue: "Choose at least one source profile to import." - ) - return - } - resetStep3State() - step = .dataTypes - validationMessage = nil - case .dataTypes: - guard let scope = BrowserImportScope.fromSelection( - includeCookies: includeCookies, - includeHistory: includeHistory, - includeAdditionalData: includeAdditionalData - ) else { - validationMessage = String( - localized: "browser.import.validation.scope", - defaultValue: "Select Cookies, History, or both before starting import." - ) - return - } - - let domainFilters = BrowserDataImporter.parseDomainFilters(domainFilterText) - selection = BrowserDataImportCoordinator.ImportSelection( - browser: selectedBrowser(), - executionPlan: currentExecutionPlan(), - scope: scope, - domainFilters: domainFilters - ) - onFinish?(.OK) - } - } - - var primaryButtonTitle: String { - switch step { - case .source, .sourceProfiles: - return String(localized: "browser.import.next", defaultValue: "Next") - case .dataTypes: - return String(localized: "browser.import.start", defaultValue: "Start Import") - } - } - - var isPrimaryButtonEnabled: Bool { - switch step { - case .source: - return true - case .sourceProfiles: - return !selectedBrowser().profiles.isEmpty - case .dataTypes: - return true - } - } - - var stepLabelText: String { - switch step { - case .source: - return String(localized: "browser.import.step.source", defaultValue: "Step 1 of 3") - case .sourceProfiles: - return String(localized: "browser.import.step.sourceProfiles", defaultValue: "Step 2 of 3") - case .dataTypes: - return String(localized: "browser.import.step.dataTypes", defaultValue: "Step 3 of 3") - } - } -} - -// MARK: - Root View - -struct BrowserImportWizardView: View { - @ObservedObject var viewModel: BrowserImportWizardViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "browser.import.title", defaultValue: "Import Browser Data")) - .font(.system(size: 22, weight: .semibold)) - - Text(viewModel.stepLabelText) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.secondary) - - switch viewModel.step { - case .source: - BrowserImportSourceStepView(viewModel: viewModel) - case .sourceProfiles: - BrowserImportSourceProfilesStepView(viewModel: viewModel) - case .dataTypes: - BrowserImportDataTypesStepView(viewModel: viewModel) - } - - if let validationMessage = viewModel.validationMessage { - Text(validationMessage) - .font(.system(size: 12)) - .foregroundColor(.red) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 8) { - Spacer() - if viewModel.step != .source { - Button(String(localized: "browser.import.back", defaultValue: "Back")) { - viewModel.handleBack() - } - } - Button(String(localized: "common.cancel", defaultValue: "Cancel")) { - viewModel.handleCancel() - } - .keyboardShortcut(.cancelAction) - Button(viewModel.primaryButtonTitle) { - viewModel.handlePrimary() - } - .keyboardShortcut(.defaultAction) - .disabled(!viewModel.isPrimaryButtonEnabled) - } - } - .padding(18) - .frame(width: 560) - } -} - -private struct BrowserImportSourceStepView: View { - @ObservedObject var viewModel: BrowserImportWizardViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(String(localized: "browser.import.source", defaultValue: "Source")) - .frame(width: 64, alignment: .trailing) - Picker("", selection: $viewModel.selectedBrowserIndex) { - ForEach(Array(viewModel.browsers.enumerated()), id: \.offset) { index, browser in - Text(browser.displayName).tag(index) - } - } - .pickerStyle(.menu) - .labelsHidden() - } - - Text(InstalledBrowserDetector.summaryText(for: viewModel.browsers)) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - } -} - -private struct BrowserImportSourceProfilesStepView: View { - @ObservedObject var viewModel: BrowserImportWizardViewModel - - var body: some View { - let browser = viewModel.selectedBrowser() - let presentation = viewModel.sourceProfilesPresentation - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "browser.import.sourceProfiles", defaultValue: "Source Profiles")) - .font(.system(size: 12, weight: .semibold)) - - if browser.profiles.isEmpty { - Text( - String( - format: String( - localized: "browser.import.sourceProfiles.empty", - defaultValue: "No source profiles detected for %@." - ), - browser.displayName - ) - ) - .font(.system(size: 12)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else { - ScrollView { - VStack(alignment: .leading, spacing: 6) { - ForEach(browser.profiles) { profile in - Toggle( - profile.displayName, - isOn: Binding( - get: { viewModel.isSourceProfileSelected(profile) }, - set: { viewModel.toggleSourceProfile(profile, isOn: $0) } - ) - ) - .toggleStyle(.checkbox) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - .frame(height: presentation.scrollHeight) - } - - if presentation.showsHelpText { - Text( - String( - localized: "browser.import.sourceProfiles.help", - defaultValue: "Choose one or more source profiles. Step 3 lets you keep them separate or merge them into one Programa profile." - ) - ) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -private struct BrowserImportDataTypesStepView: View { - @ObservedObject var viewModel: BrowserImportWizardViewModel - - var body: some View { - let plan = viewModel.currentExecutionPlan() - let presentation = viewModel.step3Presentation - - VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "browser.import.destination.cmux", defaultValue: "Programa destination")) - .font(.system(size: 12, weight: .semibold)) - - if presentation.showsModeSelector { - Picker( - "", - selection: Binding( - get: { viewModel.destinationMode == .separateProfiles ? 0 : 1 }, - set: { viewModel.setDestinationMode($0 == 0 ? .separateProfiles : .mergeIntoOne) } - ) - ) { - Text( - String( - localized: "browser.import.destinationMode.separate", - defaultValue: "Keep profiles separate" - ) - ).tag(0) - Text( - String( - localized: "browser.import.destinationMode.merge", - defaultValue: "Merge all into one Programa profile" - ) - ).tag(1) - } - .pickerStyle(.radioGroup) - .labelsHidden() - } - - if presentation.showsSeparateRows { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(plan.entries.enumerated()), id: \.offset) { index, entry in - if let sourceProfile = entry.sourceProfiles.first { - BrowserImportSeparateDestinationRow( - viewModel: viewModel, - index: index, - entry: entry, - sourceProfile: sourceProfile - ) - } - } - } - } - - if presentation.showsSingleDestinationPicker { - HStack(spacing: 6) { - Text(String(localized: "browser.import.destinationProfile", defaultValue: "Import into")) - .frame(width: 110, alignment: .trailing) - Picker( - "", - selection: Binding( - get: { - viewModel.destinationProfiles.firstIndex( - where: { $0.id == viewModel.resolvedMergeDestinationProfileID } - ) ?? 0 - }, - set: { index in - guard viewModel.destinationProfiles.indices.contains(index) else { return } - viewModel.setMergeDestination(profileID: viewModel.destinationProfiles[index].id) - } - ) - ) { - ForEach(Array(viewModel.destinationProfiles.enumerated()), id: \.offset) { index, profile in - Text(profile.displayName).tag(index) - } - } - .pickerStyle(.menu) - .labelsHidden() - .accessibilityIdentifier("BrowserImportDestinationPopup-merge") - } - } - - if presentation.showsSeparateRows { - Text( - String( - localized: "browser.import.destinationProfile.separateHelp", - defaultValue: "Missing Programa profiles are created when import starts." - ) - ) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } else if plan.entries.count > 1 { - Text( - String( - localized: "browser.import.destinationProfile.mergeHelp", - defaultValue: "All selected source profiles will be merged into the chosen Programa browser profile." - ) - ) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Toggle( - String(localized: "browser.import.cookies", defaultValue: "Cookies (site sign-ins)"), - isOn: $viewModel.includeCookies - ) - .toggleStyle(.checkbox) - .accessibilityIdentifier("BrowserImportCookiesCheckbox") - .onChange(of: viewModel.includeCookies) { viewModel.validationMessage = nil } - - Toggle( - String(localized: "browser.import.history", defaultValue: "History (visited pages)"), - isOn: $viewModel.includeHistory - ) - .toggleStyle(.checkbox) - .accessibilityIdentifier("BrowserImportHistoryCheckbox") - .onChange(of: viewModel.includeHistory) { viewModel.validationMessage = nil } - - Toggle( - String( - localized: "browser.import.additionalData", - defaultValue: "Additional data (bookmarks, settings, extensions)" - ), - isOn: $viewModel.includeAdditionalData - ) - .toggleStyle(.checkbox) - .accessibilityIdentifier("BrowserImportAdditionalDataCheckbox") - .onChange(of: viewModel.includeAdditionalData) { viewModel.validationMessage = nil } - - if viewModel.includeAdditionalData { - Text( - String( - localized: "browser.import.additionalData.note", - defaultValue: "Bookmarks, settings, and extensions import are not available yet." - ) - ) - .font(.system(size: 11)) - .foregroundColor(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - HStack(spacing: 8) { - Text(String(localized: "browser.import.domain", defaultValue: "Limit to")) - .frame(width: 72, alignment: .trailing) - TextField( - String( - localized: "browser.import.domain.placeholder", - defaultValue: "Optional domains only (e.g. github.com, openai.com)" - ), - text: $viewModel.domainFilterText - ) - } - } - } -} - -private struct BrowserImportSeparateDestinationRow: View { - @ObservedObject var viewModel: BrowserImportWizardViewModel - let index: Int - let entry: BrowserImportExecutionEntry - let sourceProfile: InstalledBrowserProfile - - var body: some View { - let options = viewModel.destinationOptions(for: entry, sourceProfile: sourceProfile) - HStack(spacing: 6) { - Text(sourceProfile.displayName) - .frame(width: 110, alignment: .trailing) - Picker( - "", - selection: Binding( - get: { options.firstIndex(of: entry.destination) ?? 0 }, - set: { newIndex in - guard options.indices.contains(newIndex) else { return } - viewModel.setSeparateDestination(at: index, to: options[newIndex]) - } - ) - ) { - ForEach(Array(options.enumerated()), id: \.offset) { optionIndex, option in - Text(viewModel.title(for: option)).tag(optionIndex) - } - } - .pickerStyle(.menu) - .labelsHidden() - .accessibilityIdentifier( - "BrowserImportDestinationPopup-\(viewModel.accessibilitySlug(for: sourceProfile, index: index))" - ) - } - } -} - -// MARK: - Window Controller - -@MainActor -final class BrowserImportWizardWindowController: NSObject, NSWindowDelegate { - private let panel: NSPanel - private let viewModel: BrowserImportWizardViewModel - private var didFinishModal = false - - init( - browsers: [InstalledBrowserCandidate], - destinationProfiles: [BrowserProfileDefinition]?, - defaultDestinationProfileID: UUID? - ) { - let viewModel = BrowserImportWizardViewModel( - browsers: browsers, - destinationProfiles: destinationProfiles, - defaultDestinationProfileID: defaultDestinationProfileID - ) - self.viewModel = viewModel - - let hostingController = NSHostingController(rootView: BrowserImportWizardView(viewModel: viewModel)) - hostingController.sizingOptions = [.preferredContentSize] - - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 560, height: 292), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - panel.title = String( - localized: "browser.import.title", - defaultValue: "Import Browser Data" - ) - panel.isReleasedWhenClosed = false - panel.standardWindowButton(.miniaturizeButton)?.isHidden = true - panel.standardWindowButton(.zoomButton)?.isHidden = true - panel.contentViewController = hostingController - self.panel = panel - - super.init() - panel.delegate = self - viewModel.onFinish = { [weak self] response in - self?.finishModal(with: response) - } - } - - func runModal() -> BrowserDataImportCoordinator.ImportSelection? { - panel.center() - panel.makeKeyAndOrderFront(nil) - NSRunningApplication.current.activate(options: [.activateAllWindows]) - - let response = NSApp.runModal(for: panel) - if panel.isVisible { - panel.orderOut(nil) - } - - guard response == .OK else { return nil } - return viewModel.selection - } - -#if DEBUG - var debugPanelWindow: NSWindow { panel } -#endif - - func windowWillClose(_ notification: Notification) { - finishModal(with: .cancel) - } - - private func finishModal(with response: NSApplication.ModalResponse) { - guard !didFinishModal else { return } - didFinishModal = true - - if NSApp.modalWindow == panel { - NSApp.stopModal(withCode: response) - } - panel.orderOut(nil) - } -} diff --git a/Sources/Panels/BrowserPanel+Navigation.swift b/Sources/Panels/BrowserPanel+Navigation.swift index 088c5b4e..5aac44f7 100644 --- a/Sources/Panels/BrowserPanel+Navigation.swift +++ b/Sources/Panels/BrowserPanel+Navigation.swift @@ -117,9 +117,9 @@ extension BrowserPanel { /// Reload the current page func reload() { webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent - if Self.serializableSessionHistoryURLString(Self.remoteProxyDisplayURL(for: webView.url)) == nil { + if Self.serializableSessionHistoryURLString(webView.url) == nil { let fallbackURL = resolvedCurrentSessionHistoryURL() - ?? Self.remoteProxyDisplayURL(for: navigationDelegate?.lastAttemptedURL) + ?? navigationDelegate?.lastAttemptedURL if let fallbackURL, Self.serializableSessionHistoryURLString(fallbackURL) != nil { @@ -142,7 +142,7 @@ extension BrowserPanel { /// Returns the most reliable URL string for omnibar-related matching and UI decisions. /// `currentURL` can lag behind navigation changes, so prefer the live WKWebView URL. func preferredURLStringForOmnibar() -> String? { - if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString + if let webViewURL = webView.url?.absoluteString .trimmingCharacters(in: .whitespacesAndNewlines), !webViewURL.isEmpty, webViewURL != blankURLString { @@ -160,7 +160,7 @@ extension BrowserPanel { } private func resolvedCurrentSessionHistoryURL() -> URL? { - if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url), + if let webViewURL = webView.url, Self.serializableSessionHistoryURLString(webViewURL) != nil { return webViewURL } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 5d70b862..bcaac8fa 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -701,11 +701,6 @@ final class BrowserPanel: Panel, ObservableObject { var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). @Published var preferredDeveloperToolsVisible: Bool = false - @Published var isReactGrabActive: Bool = false - var reactGrabMessageHandler: ReactGrabMessageHandler? - var pendingReactGrabReturnTargetPanelId: UUID? - var pendingReactGrabRoundTripToken: String? - let reactGrabBridgeSessionUpdaterName = "__programaReactGrabBridgeSync_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" @Published var isDesignModeActive: Bool = false var designModeMessageHandler: DesignModeMessageHandler? var pendingDesignModeReturnTargetPanelId: UUID? @@ -719,15 +714,6 @@ final class BrowserPanel: Panel, ObservableObject { var developerToolsRestoreRetryAttempt: Int = 0 let developerToolsRestoreRetryDelay: TimeInterval = 0.05 let developerToolsRestoreRetryMaxAttempts: Int = 40 - private var remoteProxyEndpoint: BrowserProxyEndpoint? - @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? - private var usesRemoteWorkspaceProxy: Bool - private struct PendingRemoteNavigation { - let request: URLRequest - let recordTypedNavigation: Bool - let preserveRestoredSessionHistory: Bool - } - private var pendingRemoteNavigation: PendingRemoteNavigation? private var browserStateRestoreGeneration = UUID() private var browserStateRestoreLease: TerminalController.V2BrowserStateRestoreLeaseCoordinator.Lease? private var browserStateRestoreNavigationWaiter: BrowserStateRestoreNavigationWaiter? @@ -971,13 +957,6 @@ final class BrowserPanel: Panel, ObservableObject { // This reduces repeated consent/bot-challenge flows on sites like Google. configuration.websiteDataStore = websiteDataStore - // Web extension support (proof of concept — see BrowserExtensionManager). Popups - // inherit this through the same shared-configuration path as everything else here. - if #available(macOS 15.4, *) { - configuration.webExtensionController = BrowserExtensionManager.shared.controller - BrowserExtensionManager.shared.loadInstalledExtensionsIfNeeded() - } - // Enable developer extras (DevTools) configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") configuration.preferences.isElementFullscreenEnabled = true @@ -1043,13 +1022,9 @@ final class BrowserPanel: Panel, ObservableObject { webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) - setupReactGrabMessageHandler(for: webView) setupDesignModeMessageHandler(for: webView) setupIMECompositionTracking(for: webView) setupPasskeyHandoffTracking(for: webView) - if #available(macOS 15.4, *) { - BrowserExtensionManager.shared.registerTab(for: self) - } } private func configureNavigationDelegateCallbacks() { @@ -1105,10 +1080,7 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId: UUID, profileID: UUID? = nil, initialURL: URL? = nil, - bypassInsecureHTTPHostOnce: String? = nil, - proxyEndpoint: BrowserProxyEndpoint? = nil, - isRemoteWorkspace: Bool = false, - remoteWebsiteDataStoreIdentifier: UUID? = nil + bypassInsecureHTTPHostOnce: String? = nil ) { self.id = UUID() self.workspaceId = workspaceId @@ -1119,12 +1091,8 @@ final class BrowserPanel: Panel, ObservableObject { self.profileID = resolvedProfileID self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") - self.remoteProxyEndpoint = proxyEndpoint - self.usesRemoteWorkspaceProxy = isRemoteWorkspace self.browserThemeMode = BrowserThemeSettings.mode() - self.websiteDataStore = isRemoteWorkspace - ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId) - : BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) + self.websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) let webView = Self.makeWebView( profileID: resolvedProfileID, @@ -1132,7 +1100,7 @@ final class BrowserPanel: Panel, ObservableObject { ) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } - applyRemoteProxyConfigurationIfAvailable() + applyUserProxyConfiguration() BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate @@ -1223,7 +1191,6 @@ final class BrowserPanel: Panel, ObservableObject { bindWebView(webView) installDetachedDeveloperToolsWindowCloseObserver() applyBrowserThemeModeIfNeeded() - ReactGrabScriptLoader.prefetch() insecureHTTPAlertWindowProvider = { [weak self] in self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow } @@ -1235,40 +1202,10 @@ final class BrowserPanel: Panel, ObservableObject { } } - func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { - guard remoteProxyEndpoint != endpoint else { return } - invalidateBrowserStateRestore(with: .unavailable) - remoteProxyEndpoint = endpoint - applyRemoteProxyConfigurationIfAvailable() - resumePendingRemoteNavigationIfNeeded() - } - - func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { - guard remoteWorkspaceStatus != status else { return } - remoteWorkspaceStatus = status - } - - private func applyRemoteProxyConfigurationIfAvailable() { + /// Applies the `browser.proxy` user setting to this panel's data store, or clears + /// any proxy configuration when the setting is absent or malformed. + private func applyUserProxyConfiguration() { let store = webView.configuration.websiteDataStore - - // Relay endpoint takes precedence: when active, configure both SOCKS and - // HTTP CONNECT so the SSH relay can intercept all WebView traffic. - if let endpoint = remoteProxyEndpoint { - let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, - endpoint.port > 0 && endpoint.port <= 65535, - let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { - store.proxyConfigurations = [] - return - } - let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) - let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) - let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) - store.proxyConfigurations = [socks, connect] - return - } - - // No relay endpoint — apply the user-configured proxy if set, else clear. if let descriptor = BrowserUserProxySettings.descriptor() { guard let nwPort = NWEndpoint.Port(rawValue: UInt16(descriptor.port)) else { store.proxyConfigurations = [] @@ -1303,23 +1240,12 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId = newWorkspaceId } - func reattachToWorkspace( - _ newWorkspaceId: UUID, - isRemoteWorkspace: Bool, - remoteWebsiteDataStoreIdentifier: UUID? = nil, - proxyEndpoint: BrowserProxyEndpoint?, - remoteStatus: BrowserRemoteWorkspaceStatus? - ) { + func reattachToWorkspace(_ newWorkspaceId: UUID) { invalidateBrowserStateRestoreForWorkspaceTransfer() workspaceId = newWorkspaceId - usesRemoteWorkspaceProxy = isRemoteWorkspace - let targetStore = isRemoteWorkspace - ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? newWorkspaceId) - : BrowserProfileStore.shared.websiteDataStore(for: profileID) + let targetStore = BrowserProfileStore.shared.websiteDataStore(for: profileID) let needsStoreSwap = webView.configuration.websiteDataStore !== targetStore websiteDataStore = targetStore - remoteProxyEndpoint = proxyEndpoint - remoteWorkspaceStatus = remoteStatus if needsStoreSwap { replaceWebViewPreservingState( from: webView, @@ -1327,8 +1253,7 @@ final class BrowserPanel: Panel, ObservableObject { reason: "workspace_reattach" ) } - applyRemoteProxyConfigurationIfAvailable() - resumePendingRemoteNavigationIfNeeded() + applyUserProxyConfiguration() } @discardableResult @@ -1374,9 +1299,7 @@ final class BrowserPanel: Panel, ObservableObject { historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) BrowserProfileStore.shared.noteUsed(resolvedProfileID) - if !usesRemoteWorkspaceProxy { - websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) - } + websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) let replacement = Self.makeWebView( profileID: resolvedProfileID, @@ -1463,7 +1386,7 @@ final class BrowserPanel: Panel, ObservableObject { } private func resolvedLiveSessionHistoryURL() -> URL? { - if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url), + if let webViewURL = webView.url, Self.serializableSessionHistoryURLString(webViewURL) != nil { return webViewURL } @@ -1598,7 +1521,7 @@ final class BrowserPanel: Panel, ObservableObject { let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } - self.currentURL = Self.remoteProxyDisplayURL(for: webView.url) + self.currentURL = webView.url } } webViewObservers.append(urlObserver) @@ -1707,7 +1630,7 @@ final class BrowserPanel: Panel, ObservableObject { invalidateBrowserStateRestore(with: .unavailable) let wasRenderable = shouldRenderWebView - let restoreURL = Self.remoteProxyDisplayURL(for: oldWebView.url) ?? currentURL + let restoreURL = oldWebView.url ?? currentURL let restoreURLString = restoreURL?.absoluteString let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString let history = sessionNavigationHistorySnapshot() @@ -1799,7 +1722,7 @@ final class BrowserPanel: Panel, ObservableObject { // If nothing meaningful is loaded yet, prefer letting the omnibar take focus. if !webView.isLoading { - let urlString = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString ?? currentURL?.absoluteString + let urlString = webView.url?.absoluteString ?? currentURL?.absoluteString if urlString == nil || urlString == "about:blank" { return } @@ -1863,10 +1786,6 @@ final class BrowserPanel: Panel, ObservableObject { unfocus() invalidateBrowserStateRestore(with: .cancelled) - if #available(macOS 15.4, *) { - BrowserExtensionManager.shared.unregisterTab(for: self) - } - // Snapshot first: popup close unregisters itself from popupControllers. let popupsToClose = popupControllers popupControllers.removeAll() @@ -2020,40 +1939,20 @@ final class BrowserPanel: Panel, ObservableObject { req.timeoutInterval = 2.0 req.cachePolicy = .returnCacheDataElseLoad req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") - let effectiveRequest = remoteProxyPreparedRequest(from: req, logScope: "faviconRewrite") - let data: Data let response: URLResponse do { - let remoteSession = remoteProxyURLSession() - defer { remoteSession?.finishTasksAndInvalidate() } - if let remoteSession { -#if DEBUG - dlog( - "browser.favicon.fetch " + - "panel=\(id.uuidString.prefix(5)) " + - "via=proxy " + - "url=\(effectiveRequest.url?.absoluteString ?? "<nil>")" - ) -#endif - (data, response) = try await Self.loadBoundedFaviconData( - for: effectiveRequest, - session: remoteSession - ) - } else { #if DEBUG - dlog( - "browser.favicon.fetch " + - "panel=\(id.uuidString.prefix(5)) " + - "via=direct " + - "url=\(effectiveRequest.url?.absoluteString ?? "<nil>")" - ) + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "url=\(req.url?.absoluteString ?? "<nil>")" + ) #endif - (data, response) = try await Self.loadBoundedFaviconData( - for: effectiveRequest, - session: .shared - ) - } + (data, response) = try await Self.loadBoundedFaviconData( + for: req, + session: .shared + ) } catch { #if DEBUG dlog( @@ -2329,10 +2228,8 @@ final class BrowserPanel: Panel, ObservableObject { ) -> BrowserStateRestoreNavigationStartOutcome { guard browserStateRestoreNavigationWaiter == nil else { return .busy } guard !browserShouldBlockInsecureHTTPURL(url) else { return .permissionDenied } - guard !usesRemoteWorkspaceProxy || remoteProxyEndpoint != nil else { return .unavailable } - let request = URLRequest(url: url) - let effectiveRequest = remoteProxyPreparedRequest(from: request, logScope: "stateRestore") + let effectiveRequest = URLRequest(url: url) guard let effectiveURL = effectiveRequest.url else { return .unavailable } let restoreWebView = webView @@ -2520,17 +2417,6 @@ final class BrowserPanel: Panel, ObservableObject { if preserveRestoredSessionHistory, browserShouldBlockInsecureHTTPURL(url) { dilog("browser.restore", "insecure_http_reload_without_prompt host=\(url.host ?? "-")") } - if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil { - pendingRemoteNavigation = PendingRemoteNavigation( - request: request, - recordTypedNavigation: recordTypedNavigation, - preserveRestoredSessionHistory: preserveRestoredSessionHistory - ) - shouldRenderWebView = true - currentURL = Self.remoteProxyDisplayURL(for: url) ?? url - navigationDelegate?.lastAttemptedURL = url - return - } performNavigation( request: request, originalURL: url, @@ -2539,21 +2425,6 @@ final class BrowserPanel: Panel, ObservableObject { ) } - private func resumePendingRemoteNavigationIfNeeded() { - guard remoteProxyEndpoint != nil, - let pendingRemoteNavigation else { - return - } - self.pendingRemoteNavigation = nil - guard let originalURL = pendingRemoteNavigation.request.url else { return } - performNavigation( - request: pendingRemoteNavigation.request, - originalURL: originalURL, - recordTypedNavigation: pendingRemoteNavigation.recordTypedNavigation, - preserveRestoredSessionHistory: pendingRemoteNavigation.preserveRestoredSessionHistory - ) - } - private func performNavigation( request: URLRequest, originalURL: URL, @@ -2563,7 +2434,6 @@ final class BrowserPanel: Panel, ObservableObject { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } - let effectiveRequest = remoteProxyPreparedRequest(from: request, logScope: "rewrite") // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -2571,53 +2441,7 @@ final class BrowserPanel: Panel, ObservableObject { historyStore.recordTypedNavigation(url: originalURL) } navigationDelegate?.lastAttemptedURL = originalURL - browserLoadRequest(effectiveRequest, in: webView) - } - - private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { - guard remoteProxyEndpoint != nil else { return request } - guard let url = request.url else { return request } - guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } - - var rewrittenRequest = request - rewrittenRequest.url = rewrittenURL -#if DEBUG - dlog( - "browser.remoteProxy.\(logScope) " + - "panel=\(id.uuidString.prefix(5)) " + - "from=\(url.absoluteString) " + - "to=\(rewrittenURL.absoluteString)" - ) -#endif - return rewrittenRequest - } - - private func remoteProxyURLSession() -> URLSession? { - guard let endpoint = remoteProxyEndpoint else { return nil } - let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, endpoint.port > 0, endpoint.port <= 65535 else { return nil } - - let configuration = URLSessionConfiguration.ephemeral - configuration.requestCachePolicy = .returnCacheDataElseLoad - configuration.timeoutIntervalForRequest = 2.0 - configuration.timeoutIntervalForResource = 4.0 - configuration.connectionProxyDictionary = [ - kCFNetworkProxiesSOCKSEnable as String: 1, - kCFNetworkProxiesSOCKSProxy as String: host, - kCFNetworkProxiesSOCKSPort as String: endpoint.port, - ] - return URLSession(configuration: configuration) - } - - static func remoteProxyDisplayURL(for url: URL?) -> URL? { - WorkspaceRemoteLoopbackPolicy.displayURL(for: url) - } - - // Internal so the browser-to-proxy routing contract can be exercised as one - // behavioral path by the unit tests. Keep this as the production implementation, - // rather than duplicating the URL transformation in a test-only helper. - static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { - WorkspaceRemoteLoopbackPolicy.browserAliasURL(for: url) + browserLoadRequest(request, in: webView) } /// Navigate with smart URL/search detection diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 1437144d..a206ee0e 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -138,8 +138,6 @@ struct BrowserPanelView: View { @AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey) private var browserProfilePopoverVerticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue - @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs - @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(ProgramaGlassSettings.browserToolbarEnabledKey) private var browserToolbarLiquidGlassEnabled = false @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @@ -147,16 +145,12 @@ struct BrowserPanelView: View { @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @State private var latestRemoteSuggestions: [String] = [] - @State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = [] - @State private var emptyStateImportBrowserRefreshTask: Task<Void, Never>? - @State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0 @State private var inlineCompletion: OmnibarInlineCompletion? @State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0) @State private var omnibarHasMarkedText: Bool = false @State private var suppressNextFocusLostRevert: Bool = false @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 - @State private var isBrowserImportHintPopoverPresented = false @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var pendingAddressBarFocusRetryRequestId: UUID? @State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0 @@ -209,13 +203,6 @@ struct BrowserPanelView: View { BrowserThemeSettings.mode(for: browserThemeModeRaw) } - private var browserImportHintPresentation: BrowserImportHintPresentation { - BrowserImportHintPresentation( - showOnBlankTabs: showBrowserImportHintOnBlankTabs, - isDismissed: isBrowserImportHintDismissed - ) - } - private var browserToolbarAccessorySpacing: CGFloat { CGFloat(BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)) } @@ -267,14 +254,6 @@ struct BrowserPanelView: View { return "\(base) (\(KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools).displayString))" } - private var browserImportHintSummary: String { - InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers) - } - - private var shouldShowToolbarImportHintChip: Bool { - shouldShowEmptyStateImportOverlay && browserImportHintPresentation.blankTabPlacement == .toolbarChip - } - private var owningWorkspace: Workspace? { guard let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: panel.workspaceId) else { @@ -423,7 +402,6 @@ struct BrowserPanelView: View { // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. autoFocusOmnibarIfBlank() syncWebViewResponderPolicyWithViewState(reason: "onAppear") - refreshEmptyStateImportBrowsers() panel.historyStore.loadIfNeeded() #if DEBUG logBrowserFocusState(event: "view.onAppear") @@ -440,13 +418,6 @@ struct BrowserPanelView: View { !isWebViewBlank() { setAddressBarFocused(false, reason: "panel.currentURL.loaded") } - if isWebViewBlank() { - refreshEmptyStateImportBrowsers() - } - panel.resetReactGrabState( - preserveRoundTrip: true, - reason: "panel.currentURL.changed" - ) } .onChange(of: browserThemeModeRaw) { let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw) @@ -620,10 +591,6 @@ struct BrowserPanelView: View { .accessibilityLabel(String(localized: "browser.omnibar.accessibilityLabel", defaultValue: "Browser omnibar")) HStack(spacing: browserToolbarAccessorySpacing) { - if shouldShowToolbarImportHintChip { - browserImportHintToolbarChip - } - reactGrabButton BrowserProfileMenuView( panel: panel, iconColor: devToolsColorOption.color, @@ -634,8 +601,6 @@ struct BrowserPanelView: View { switch action { case .newProfile: presentCreateBrowserProfilePrompt() - case .importBrowserData: - presentImportDialogFromProfileMenu() case .renameProfile: presentRenameBrowserProfilePrompt() } @@ -647,9 +612,6 @@ struct BrowserPanelView: View { isPresented: $isBrowserThemeMenuPresented, onSelectMode: applyBrowserThemeModeSelection ) - if #available(macOS 15.4, *) { - browserExtensionsButton - } developerToolsButton } } @@ -676,44 +638,6 @@ struct BrowserPanelView: View { } } - private var reactGrabButton: some View { - Button(action: { - panel.clearReactGrabRoundTrip(reason: "toolbarButton.manualStart") - Task { await panel.toggleOrInjectReactGrab() } - }) { - Image(systemName: "cursorarrow.click.2") - .symbolRenderingMode(.monochrome) - .programaFlatSymbolColorRendering() - .font(.system(size: devToolsButtonIconSize, weight: .medium)) - .foregroundStyle(panel.isReactGrabActive ? Color.accentColor : Color.secondary) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - } - .buttonStyle(OmnibarAddressButtonStyle()) - .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) - .safeHelp(String(localized: "browser.reactGrab", defaultValue: "Inject React Grab")) - .accessibilityIdentifier("BrowserReactGrabButton") - } - - @available(macOS 15.4, *) - private var browserExtensionsButton: some View { - let label = String(localized: "browser.extensions.manage", defaultValue: "Manage Browser Extensions") - return Button { - BrowserExtensionManager.shared.presentManagementUI() - } label: { - Image(systemName: "puzzlepiece.extension") - .symbolRenderingMode(.monochrome) - .programaFlatSymbolColorRendering() - .font(.system(size: devToolsButtonIconSize, weight: .medium)) - .foregroundStyle(devToolsColorOption.color) - .frame(width: 44, height: 44, alignment: .center) - } - .buttonStyle(OmnibarAddressButtonStyle()) - .frame(width: 44, height: 44, alignment: .center) - .accessibilityLabel(label) - .safeHelp(label) - .accessibilityIdentifier("BrowserManageExtensionsButton") - } - private var developerToolsButton: some View { Button(action: { openDevTools() @@ -731,42 +655,10 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserToggleDevToolsButton") } - private var browserImportHintToolbarChip: some View { - Button(action: { - isBrowserImportHintPopoverPresented.toggle() - }) { - HStack(spacing: 4) { - Image(systemName: "square.and.arrow.down.on.square") - .symbolRasterSize(10, weight: .medium) - Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - .foregroundStyle(devToolsColorOption.color) - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - .buttonStyle(OmnibarAddressButtonStyle()) - .popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) { - browserImportHintContent.popover - } - .safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data")) - .accessibilityIdentifier("BrowserImportHintToolbarChip") - } - private var browserThemeModeIconColor: Color { devToolsColorOption.color } - private var browserImportHintContent: BrowserImportHintContentView { - BrowserImportHintContentView( - summary: browserImportHintSummary, - onImport: presentImportDialogFromHint, - onOpenSettings: openBrowserImportSettings, - onDismiss: dismissBrowserImportHint - ) - } - private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" @@ -1150,40 +1042,6 @@ struct BrowserPanelView: View { #endif } - private var shouldShowEmptyStateImportOverlay: Bool { - !panel.shouldRenderWebView && isWebViewBlank() - } - - private func presentImportDialogFromHint() { - isBrowserImportHintPopoverPresented = false - // Let the popover fully dismiss before entering the modal import flow. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } - } - - private func presentImportDialogFromProfileMenu() { - isBrowserProfileMenuPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog( - defaultDestinationProfileID: panel.profileID - ) - } - } - - private func openBrowserImportSettings() { - isBrowserImportHintPopoverPresented = false - AppDelegate.presentPreferencesWindow(navigationTarget: .browserImport) - } - - private func dismissBrowserImportHint() { - showBrowserImportHintOnBlankTabs = false - isBrowserImportHintDismissed = true - isBrowserImportHintPopoverPresented = false - } - /// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes. private func isWebViewBlank() -> Bool { guard let url = panel.webView.url else { return true } @@ -1235,31 +1093,6 @@ struct BrowserPanelView: View { #endif } - private func refreshEmptyStateImportBrowsers() { - emptyStateImportBrowserRefreshTask?.cancel() - emptyStateImportBrowserRefreshGeneration &+= 1 - let generation = emptyStateImportBrowserRefreshGeneration - - guard shouldShowEmptyStateImportOverlay else { - emptyStateImportBrowsers = [] - emptyStateImportBrowserRefreshTask = nil - return - } - - emptyStateImportBrowserRefreshTask = Task { - let browsers = await Task.detached(priority: .utility) { - InstalledBrowserDetector.detectInstalledBrowsers() - }.value - guard !Task.isCancelled else { return } - await MainActor.run { - guard emptyStateImportBrowserRefreshGeneration == generation, - shouldShowEmptyStateImportOverlay else { return } - emptyStateImportBrowsers = browsers - emptyStateImportBrowserRefreshTask = nil - } - } - } - private func openDevTools() { #if DEBUG dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") diff --git a/Sources/Panels/BrowserSettings.swift b/Sources/Panels/BrowserSettings.swift index ee483810..49cd7abf 100644 --- a/Sources/Panels/BrowserSettings.swift +++ b/Sources/Panels/BrowserSettings.swift @@ -7,13 +7,6 @@ struct BrowserProxyEndpoint: Equatable { let port: Int } -struct BrowserRemoteWorkspaceStatus: Equatable { - let target: String - let connectionState: WorkspaceRemoteConnectionState - let heartbeatCount: Int - let lastHeartbeatAt: Date? -} - enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) @@ -212,68 +205,6 @@ enum BrowserThemeSettings { } } -enum BrowserImportHintBlankTabPlacement: Equatable { - case hidden - case toolbarChip -} - -enum BrowserImportHintSettingsStatus: Equatable { - case visible - case hidden -} - -struct BrowserImportHintPresentation: Equatable { - let blankTabPlacement: BrowserImportHintBlankTabPlacement - let settingsStatus: BrowserImportHintSettingsStatus - - init( - showOnBlankTabs: Bool, - isDismissed: Bool - ) { - if !showOnBlankTabs || isDismissed { - blankTabPlacement = .hidden - settingsStatus = .hidden - return - } - - blankTabPlacement = .toolbarChip - settingsStatus = .visible - } -} - -enum BrowserImportHintSettings { - static let showOnBlankTabsKey = "browserImportHintShowOnBlankTabs" - static let dismissedKey = "browserImportHintDismissed" - static let defaultShowOnBlankTabs = true - static let defaultDismissed = false - - static func showOnBlankTabs(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: showOnBlankTabsKey) == nil { - return defaultShowOnBlankTabs - } - return defaults.bool(forKey: showOnBlankTabsKey) - } - - static func isDismissed(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: dismissedKey) == nil { - return defaultDismissed - } - return defaults.bool(forKey: dismissedKey) - } - - static func presentation(defaults: UserDefaults = .standard) -> BrowserImportHintPresentation { - BrowserImportHintPresentation( - showOnBlankTabs: showOnBlankTabs(defaults: defaults), - isDismissed: isDismissed(defaults: defaults) - ) - } - - static func reset(defaults: UserDefaults = .standard) { - defaults.set(defaultShowOnBlankTabs, forKey: showOnBlankTabsKey) - defaults.set(defaultDismissed, forKey: dismissedKey) - } -} - enum BrowserLinkOpenSettings { static let openTerminalLinksInProgramaBrowserKey = "browserOpenTerminalLinksInProgramaBrowser" static let defaultOpenTerminalLinksInProgramaBrowser: Bool = true diff --git a/Sources/Panels/BrowserToolbarViews.swift b/Sources/Panels/BrowserToolbarViews.swift index b077ae23..51656c29 100644 --- a/Sources/Panels/BrowserToolbarViews.swift +++ b/Sources/Panels/BrowserToolbarViews.swift @@ -298,7 +298,6 @@ struct BrowserNavigationButtonsView: View { struct BrowserProfileMenuView: View { enum Action { case newProfile - case importBrowserData case renameProfile } @@ -384,14 +383,6 @@ struct BrowserProfileMenuView: View { } .buttonStyle(.plain) - Button { - onAction(.importBrowserData) - } label: { - Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) - .font(.system(size: 12)) - } - .buttonStyle(.plain) - if browserProfileStore.canRenameProfile(id: panel.profileID) { Button { isPresented = false @@ -480,79 +471,3 @@ struct BrowserThemeModeMenuView: View { } } -/// Groups the browser-data import hint content shared by the blank-tab -/// overlays (floating card / inline strip) and the toolbar-chip popover. -/// All three presentations render the same hint body and action buttons. -struct BrowserImportHintContentView { - let summary: String - let onImport: () -> Void - let onOpenSettings: () -> Void - let onDismiss: () -> Void - - var popover: some View { - hintBody - .padding(12) - .frame(width: 300, alignment: .leading) - } - - private var hintBody: some View { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data")) - .font(.system(size: 12.5, weight: .semibold)) - - Text(summary) - .font(.system(size: 11.5)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser.")) - .font(.system(size: 10.5)) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - - ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { - primaryButton - settingsButton - dismissButton - } - - VStack(alignment: .leading, spacing: 8) { - primaryButton - HStack(spacing: 10) { - settingsButton - dismissButton - } - } - } - } - .accessibilityElement(children: .contain) - } - - private var primaryButton: some View { - Button(String(localized: "browser.import.hint.import", defaultValue: "Import…")) { - onImport() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("BrowserImportHintImportButton") - } - - private var settingsButton: some View { - Button(String(localized: "browser.import.hint.settings", defaultValue: "Browser Settings")) { - onOpenSettings() - } - .buttonStyle(.plain) - .controlSize(.small) - .accessibilityIdentifier("BrowserImportHintSettingsButton") - } - - private var dismissButton: some View { - Button(String(localized: "browser.import.hint.dismiss", defaultValue: "Hide Hint")) { - onDismiss() - } - .buttonStyle(.plain) - .controlSize(.small) - .accessibilityIdentifier("BrowserImportHintDismissButton") - } -} diff --git a/Sources/Panels/DesignMode.swift b/Sources/Panels/DesignMode.swift index c5d766f0..26a65191 100644 --- a/Sources/Panels/DesignMode.swift +++ b/Sources/Panels/DesignMode.swift @@ -4,19 +4,56 @@ import AppKit // MARK: - Design Mode // -// Design Mode is a click-to-select picker for the in-app browser panel, modeled directly on -// ReactGrab.swift's round-trip architecture (panel routing, WKScriptMessageHandler bridge, -// NotificationCenter pasteback to a terminal panel). Unlike ReactGrab it does NOT fetch any +// Design Mode is a click-to-select picker for the in-app browser panel. It does not fetch any // script over the network -- the picker is a fully self-contained JS string embedded below. // // Flow: TabManager.toggleDesignModeFromCurrentFocus() / the "browser.design_mode.toggle" socket -// method both call `activateDesignModeRoute(in:)`, which resolves a route via the *same* -// `resolveReactGrabShortcutRoute` used by React Grab (the focused-panel routing rule is -// panel-agnostic), arms a round trip on the target BrowserPanel, and injects/toggles the -// picker script. When the user clicks an element, the picker posts a `pick` message across the -// bridge; BrowserPanel crops a screenshot and posts `.designModeDidCapture`, which AppDelegate -// observes to write the screenshot to disk, compose the text block, and deliver it to the -// return terminal panel via the shared `sendTextWhenReady` pasteback path. +// method both call `activateDesignModeRoute(in:)`, which resolves a route via +// `resolveDesignModeShortcutRoute` below (the focused-panel routing rule is panel-agnostic; +// it originated in the removed React Grab feature, see docs/removed/browser-react-grab.md), +// arms a round trip on the target BrowserPanel, and +// injects/toggles the picker script. When the user clicks an element, the picker posts a `pick` +// message across the bridge; BrowserPanel crops a screenshot and posts `.designModeDidCapture`, +// which AppDelegate observes to write the screenshot to disk, compose the text block, and deliver +// it to the return terminal panel via the shared `sendTextWhenReady` pasteback path. + +// MARK: - Shared panel routing + +struct DesignModeShortcutPanelSnapshot: Equatable { + let id: UUID + let panelType: PanelType + let isFocused: Bool +} + +struct DesignModeShortcutRoute: Equatable { + let browserPanelId: UUID + let returnTerminalPanelId: UUID? +} + +func resolveDesignModeShortcutRoute( + panels: [DesignModeShortcutPanelSnapshot] +) -> DesignModeShortcutRoute? { + guard let focusedPanel = panels.first(where: \.isFocused) else { return nil } + + if focusedPanel.panelType == .browser { + return DesignModeShortcutRoute( + browserPanelId: focusedPanel.id, + returnTerminalPanelId: nil + ) + } + + guard focusedPanel.panelType == .terminal else { return nil } + + let browserPanels = panels.filter { $0.panelType == .browser } + guard browserPanels.count == 1, let browserPanel = browserPanels.first else { + return nil + } + + return DesignModeShortcutRoute( + browserPanelId: browserPanel.id, + returnTerminalPanelId: focusedPanel.id + ) +} // MARK: - Payload @@ -177,7 +214,7 @@ enum DesignModeNotificationKey { // MARK: - Panel routing (shared by command palette + socket entry points) -/// Resolves a Design Mode route the same way React Grab's keyboard shortcut does (focused +/// Resolves a Design Mode route from the focused panel (focused /// browser panel is the target with no return; focused terminal panel requires exactly one /// browser panel in the workspace) and drives the picker's arm/focus/inject sequence. /// Returns `false` when no route could be resolved (e.g. no browser panel in the workspace). @@ -185,13 +222,13 @@ enum DesignModeNotificationKey { @MainActor func activateDesignModeRoute(in workspace: Workspace) -> Bool { let snapshots = workspace.panels.values.map { panel in - ReactGrabShortcutPanelSnapshot( + DesignModeShortcutPanelSnapshot( id: panel.id, panelType: panel.panelType, isFocused: panel.id == workspace.focusedPanelId ) } - guard let route = resolveReactGrabShortcutRoute(panels: snapshots), + guard let route = resolveDesignModeShortcutRoute(panels: snapshots), let browserPanel = workspace.browserPanel(for: route.browserPanelId) else { return false } @@ -465,7 +502,7 @@ extension BrowserPanel { case .pick(let payload): guard let returnPanelId = pendingDesignModeReturnTargetPanelId else { // No return terminal armed (e.g. focused directly inside the browser panel with - // no terminal to return to) -- mirrors React Grab's noReturnTarget drop behavior. + // no terminal to return to): drop the capture. return } clearDesignModeRoundTrip(reason: "pick") diff --git a/Sources/Panels/ReactGrab.swift b/Sources/Panels/ReactGrab.swift deleted file mode 100644 index 585e542b..00000000 --- a/Sources/Panels/ReactGrab.swift +++ /dev/null @@ -1,474 +0,0 @@ -import CryptoKit -import Foundation -import WebKit - -#if DEBUG -import Bonsplit -#endif - -// MARK: - Settings - -enum ReactGrabSettings { - static let defaultVersion = "0.1.29" - - /// Known versions and their SHA-256 integrity hashes. - /// Add new entries when bumping the default or to allow user-selected versions. - static let knownHashes: [String: String] = [ - "0.1.29": "4a1e71090e8ad8bb6049de80ccccdc0f5bb147b9f8fb88886d871612ac7ca04b", - ] - - static func scriptURL(for version: String) -> URL { - URL(string: "https://unpkg.com/react-grab@\(version)/dist/index.global.js")! - } -} - -struct ReactGrabShortcutPanelSnapshot: Equatable { - let id: UUID - let panelType: PanelType - let isFocused: Bool -} - -struct ReactGrabShortcutRoute: Equatable { - let browserPanelId: UUID - let returnTerminalPanelId: UUID? -} - -func resolveReactGrabShortcutRoute( - panels: [ReactGrabShortcutPanelSnapshot] -) -> ReactGrabShortcutRoute? { - guard let focusedPanel = panels.first(where: \.isFocused) else { return nil } - - if focusedPanel.panelType == .browser { - return ReactGrabShortcutRoute( - browserPanelId: focusedPanel.id, - returnTerminalPanelId: nil - ) - } - - guard focusedPanel.panelType == .terminal else { return nil } - - let browserPanels = panels.filter { $0.panelType == .browser } - guard browserPanels.count == 1, let browserPanel = browserPanels.first else { - return nil - } - - return ReactGrabShortcutRoute( - browserPanelId: browserPanel.id, - returnTerminalPanelId: focusedPanel.id - ) -} - -enum ReactGrabPastebackNotificationKey { - static let workspaceId = "workspaceId" - static let browserPanelId = "browserPanelId" - static let returnPanelId = "returnPanelId" - static let content = "content" -} - -private enum ReactGrabPastebackContentFilter { - private static let dangerousScalars: Set<Unicode.Scalar> = [ - "\u{200B}", "\u{200C}", "\u{200D}", "\u{200E}", "\u{200F}", - "\u{202A}", "\u{202B}", "\u{202C}", "\u{202D}", "\u{202E}", - "\u{2066}", "\u{2067}", "\u{2068}", "\u{2069}", - "\u{FEFF}", - ] - - static func filtered(_ text: String) -> String { - String(text.unicodeScalars.filter { !dangerousScalars.contains($0) }) - } -} - -// MARK: - Script Loader - -/// Fetches, integrity-checks, and caches the react-grab script. -/// Shared across all BrowserPanel instances. -enum ReactGrabScriptLoader { - private static var cachedScript: String? - private static var cachedVersion: String? - private static var prefetchTask: Task<String?, Never>? - - static func prefetch() { - let version = ReactGrabSettings.defaultVersion - // Invalidate cache if version changed. - if cachedVersion != version { - cachedScript = nil - cachedVersion = nil - } - guard cachedScript == nil else { return } - guard prefetchTask == nil else { return } - prefetchTask = Task.detached(priority: .low) { - let result = await doFetch(version: version) - await MainActor.run { prefetchTask = nil } - return result - } - } - - static func fetch() async -> String? { - let version = ReactGrabSettings.defaultVersion - if cachedVersion == version, let cached = cachedScript { return cached } - prefetch() - return await prefetchTask?.value - } - - private static func doFetch(version: String) async -> String? { - let url = ReactGrabSettings.scriptURL(for: version) - do { - let (data, _) = try await URLSession.shared.data(from: url) - if let expectedHash = ReactGrabSettings.knownHashes[version] { - let hash = SHA256.hash(data: data) - let hex = hash.compactMap { String(format: "%02x", $0) }.joined() - guard hex == expectedHash else { - NSLog("ReactGrab: integrity mismatch for v%@ (got %@)", version, hex) - return nil - } - } - guard let script = String(data: data, encoding: .utf8) else { return nil } - await MainActor.run { - cachedScript = script - cachedVersion = version - } - return script - } catch { - NSLog("ReactGrab: fetch failed for v%@: %@", version, error.localizedDescription) - return nil - } - } -} - -// MARK: - WKScriptMessageHandler - -private let reactGrabMessageHandlerName = "programaReactGrab" - -enum ReactGrabBridgeMessage { - case stateChange(isActive: Bool) - case copySuccess(content: String, token: String?) - - init?(body: [String: Any]) { - let type = body["type"] as? String ?? "stateChange" - switch type { - case "stateChange": - guard let isActive = body["isActive"] as? Bool else { return nil } - self = .stateChange(isActive: isActive) - case "copySuccess": - guard let content = body["content"] as? String else { return nil } - self = .copySuccess(content: content, token: body["token"] as? String) - default: - return nil - } - } -} - -class ReactGrabMessageHandler: NSObject, WKScriptMessageHandler { - private let onMessage: @MainActor (ReactGrabBridgeMessage) -> Void - - init(onMessage: @escaping @MainActor (ReactGrabBridgeMessage) -> Void) { - self.onMessage = onMessage - } - - func userContentController( - _ userContentController: WKUserContentController, - didReceive message: WKScriptMessage - ) { - guard let body = message.body as? [String: Any], - let bridgeMessage = ReactGrabBridgeMessage(body: body) else { return } - #if DEBUG - switch bridgeMessage { - case .stateChange(let isActive): - dlog("reactGrab.messageHandler type=stateChange isActive=\(isActive)") - case .copySuccess(let content, _): - dlog("reactGrab.messageHandler type=copySuccess len=\(content.count)") - } - #endif - Task { @MainActor in - #if DEBUG - switch bridgeMessage { - case .stateChange(let isActive): - dlog("reactGrab.messageHandler.mainActor type=stateChange isActive=\(isActive)") - case .copySuccess(let content, _): - dlog("reactGrab.messageHandler.mainActor type=copySuccess len=\(content.count)") - } - #endif - onMessage(bridgeMessage) - } - } -} - -// MARK: - BrowserPanel extension - -extension BrowserPanel { - private func reactGrabSessionTokenLiteral() -> String { - pendingReactGrabRoundTripToken.map { "'\($0)'" } ?? "null" - } - - private func reactGrabBridgeSessionRefreshScript() -> String { - """ - (function() { - var syncToken = window['\(reactGrabBridgeSessionUpdaterName)']; - if (typeof syncToken !== 'function') { - return false; - } - return !!syncToken(\(reactGrabSessionTokenLiteral())); - })(); - """ - } - - func setupReactGrabMessageHandler(for webView: WKWebView) { - let handler = ReactGrabMessageHandler { [weak self] message in - self?.handleReactGrabBridgeMessage(message) - } - reactGrabMessageHandler = handler - webView.configuration.userContentController.add(handler, name: reactGrabMessageHandlerName) - } - - func armReactGrabRoundTrip(returnTo panelId: UUID) { - let token = UUID().uuidString -#if DEBUG - dlog( - "reactGrab.pasteback h3.arm " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) " + - "return=\(panelId.uuidString.prefix(5))" - ) -#endif - pendingReactGrabReturnTargetPanelId = panelId - pendingReactGrabRoundTripToken = token - } - - func clearReactGrabRoundTrip(reason: String = "unspecified") { -#if DEBUG - let previousTarget = pendingReactGrabReturnTargetPanelId.map { - String($0.uuidString.prefix(5)) - } ?? "nil" - dlog( - "reactGrab.pasteback h3.clear " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) " + - "reason=\(reason) previous=\(previousTarget)" - ) -#endif - pendingReactGrabReturnTargetPanelId = nil - pendingReactGrabRoundTripToken = nil - } - - func handleReactGrabBridgeMessage(_ message: ReactGrabBridgeMessage) { - switch message { - case .stateChange(let isActive): - isReactGrabActive = isActive -#if DEBUG - let pendingTarget = pendingReactGrabReturnTargetPanelId.map { - String($0.uuidString.prefix(5)) - } ?? "nil" - dlog( - "reactGrab.pasteback h3.stateChange " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) " + - "isActive=\(isActive ? 1 : 0) pending=\(pendingTarget)" - ) -#endif - case .copySuccess(let content, let token): - guard let returnPanelId = pendingReactGrabReturnTargetPanelId, - let expectedToken = pendingReactGrabRoundTripToken else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.copySuccess.drop " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) reason=noReturnTarget len=\(content.count)" - ) -#endif - return - } - guard token == expectedToken else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.copySuccess.drop " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) reason=tokenMismatch len=\(content.count)" - ) -#endif - clearReactGrabRoundTrip(reason: "copySuccess.tokenMismatch") - return - } -#if DEBUG - dlog( - "reactGrab.pasteback h3.copySuccess " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) " + - "return=\(returnPanelId.uuidString.prefix(5)) len=\(content.count)" - ) -#endif - let filteredContent = ReactGrabPastebackContentFilter.filtered(content) - clearReactGrabRoundTrip(reason: "copySuccess") - NotificationCenter.default.post( - name: .reactGrabDidCopySelection, - object: nil, - userInfo: [ - ReactGrabPastebackNotificationKey.workspaceId: workspaceId, - ReactGrabPastebackNotificationKey.browserPanelId: id, - ReactGrabPastebackNotificationKey.returnPanelId: returnPanelId, - ReactGrabPastebackNotificationKey.content: filteredContent, - ] - ) - } - } - - func injectReactGrab() async { - #if DEBUG - dlog("reactGrab.inject.start") - #endif - guard let scriptSource = await ReactGrabScriptLoader.fetch() else { - #if DEBUG - dlog("reactGrab.inject.fetchFailed") - #endif - return - } - #if DEBUG - dlog("reactGrab.inject.fetched len=\(scriptSource.count)") - #endif - - let handlerName = reactGrabMessageHandlerName - let sessionTokenLiteral = reactGrabSessionTokenLiteral() - let combined = """ - (function() { - var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(handlerName); - var updaterName = '\(reactGrabBridgeSessionUpdaterName)'; - var refreshSessionToken = function() { - var syncToken = window[updaterName]; - if (typeof syncToken !== 'function') return false; - return !!syncToken(\(sessionTokenLiteral)); - }; - var installBridge = function(api) { - if (!api || window.__PROGRAMA_REACT_GRAB_BRIDGE_INSTALLED__) return; - window.__PROGRAMA_REACT_GRAB_BRIDGE_INSTALLED__ = true; - var activeToken = null; - var syncSessionToken = function(token) { - activeToken = (typeof token === 'string' && token.length > 0) ? token : null; - return true; - }; - try { - Object.defineProperty(window, updaterName, { - value: syncSessionToken, - writable: false, - configurable: false, - enumerable: false - }); - } catch (_) { - if (typeof window[updaterName] !== 'function') return; - } - refreshSessionToken(); - var lastActive; - api.registerPlugin({ - name: 'cmux-bridge', - hooks: { - onStateChange: function(state) { - if (state.isActive === lastActive) return; - lastActive = state.isActive; - if (handler) handler.postMessage({ type: 'stateChange', isActive: state.isActive }); - }, - onCopySuccess: function(elements, content) { - var token = activeToken; - activeToken = null; - if (handler) handler.postMessage({ type: 'copySuccess', content: String(content || ''), token: token }); - } - } - }); - } - if (window.__REACT_GRAB__) { - installBridge(window.__REACT_GRAB__); - refreshSessionToken(); - window.__REACT_GRAB__.activate(); - return; - } - window.addEventListener('react-grab:init', function(e) { - var api = e.detail; - if (!api) return; - installBridge(api); - refreshSessionToken(); - api.activate(); - }, { once: true }); - })(); - \(scriptSource) - """ - #if DEBUG - dlog("reactGrab.inject.evalJS len=\(combined.count)") - #endif - do { - _ = try await webView.evaluateJavaScript(combined) - #if DEBUG - dlog("reactGrab.inject.evalJS.done error=none") - #endif - } catch { - #if DEBUG - dlog("reactGrab.inject.evalJS.done error=\(error.localizedDescription)") - #endif - NSLog("ReactGrab: injection failed: %@", error.localizedDescription) - isReactGrabActive = false - } - #if DEBUG - dlog("reactGrab.inject.end") - #endif - } - - func toggleReactGrab() { - #if DEBUG - dlog("reactGrab.toggle.start") - #endif - let script = "window.__REACT_GRAB__?.toggle()" - webView.evaluateJavaScript(script, completionHandler: nil) - #if DEBUG - dlog("reactGrab.toggle.end") - #endif - } - - func toggleOrInjectReactGrab() async { - if isReactGrabActive { - toggleReactGrab() - } else { - await injectReactGrab() - } - } - - func ensureReactGrabActive() async { - if isReactGrabActive { - guard pendingReactGrabRoundTripToken != nil else { return } - if await refreshReactGrabBridgeSessionToken() { - return - } - } - await injectReactGrab() - } - - @discardableResult - func refreshReactGrabBridgeSessionToken() async -> Bool { - do { - let result = try await evaluateJavaScript(reactGrabBridgeSessionRefreshScript()) - return (result as? Bool) ?? false - } catch { -#if DEBUG - dlog("reactGrab.bridgeSessionRefresh.error error=\(error.localizedDescription)") -#endif - return false - } - } - - func resetReactGrabState( - preserveRoundTrip: Bool = false, - reason: String = "unspecified" - ) { -#if DEBUG - let pendingTarget = pendingReactGrabReturnTargetPanelId.map { - String($0.uuidString.prefix(5)) - } ?? "nil" - dlog( - "reactGrab.pasteback h3.reset " + - "workspace=\(workspaceId.uuidString.prefix(5)) " + - "browser=\(id.uuidString.prefix(5)) " + - "reason=\(reason) preserve=\(preserveRoundTrip ? 1 : 0) " + - "pending=\(pendingTarget) active=\(isReactGrabActive ? 1 : 0)" - ) -#endif - isReactGrabActive = false - if !preserveRoundTrip { - clearReactGrabRoundTrip(reason: reason) - } - } -} diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index 2f580763..bcd2b2f3 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -82,7 +82,6 @@ struct programaApp: App { @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage(MobileBridgeSettings.appStorageKey) private var mobileBridgeMode = MobileBridgeSettings.defaultMode.rawValue @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @@ -103,11 +102,9 @@ struct programaApp: App { SessionEscrowHolder.runIfRequested() // Writing to a socket whose peer has hung up raises SIGPIPE, whose - // default disposition kills the process. The mobile bridge relays - // bytes to/from a phone that can disconnect at any moment (see - // `Sources/MobileBridge/MobileBridgeSession.swift`), so this must be - // ignored and surfaced as an EPIPE write error instead (mirrors - // `tools/mobile-spike/Sources/iroh-spike/App.swift`). + // default disposition kills the process. Programa's socket/CLI + // clients can disconnect at any moment, so this must be ignored and + // surfaced as an EPIPE write error instead. signal(SIGPIPE, SIG_IGN) UITestLaunchManifest.applyIfPresent() @@ -280,9 +277,6 @@ struct programaApp: App { #endif // Start the Unix socket controller for programmatic access updateSocketController() - // Start the mobile companion bridge (M1) if enabled -- binds - // asynchronously off the main thread, never blocking launch. - updateMobileBridgeController() appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) programaConfigStore.wireDirectoryTracking(tabManager: tabManager) programaConfigStore.loadAll() @@ -299,9 +293,6 @@ struct programaApp: App { .onChange(of: socketControlMode) { updateSocketController() } - .onChange(of: mobileBridgeMode) { - updateMobileBridgeController() - } } .windowStyle(.hiddenTitleBar) .commands { @@ -538,16 +529,6 @@ struct programaApp: App { AppDelegate.shared?.showOpenFolderPanel() } - Button( - String( - localized: "menu.file.openFolderInVSCodeInline", - defaultValue: "Open Folder in VS Code (Inline)…" - ) - ) { - AppDelegate.shared?.showOpenFolderInInlineVSCodePanel() - } - .disabled(!TerminalDirectoryOpenTarget.vscodeInline.isAvailable()) - Button( String( localized: "menu.file.installClaudeIntegration", @@ -697,12 +678,6 @@ struct programaApp: App { } } - splitCommandButton(title: String(localized: "menu.view.toggleReactGrab", defaultValue: "Toggle React Grab"), shortcut: menuShortcut(for: .toggleReactGrab)) { - if !activeTabManager.toggleReactGrabFromCurrentFocus() { - NSSound.beep() - } - } - splitCommandButton(title: String(localized: "menu.view.zoomIn", defaultValue: "Zoom In"), shortcut: menuShortcut(for: .browserZoomIn)) { _ = activeTabManager.zoomInFocusedBrowser() } @@ -719,13 +694,6 @@ struct programaApp: App { BrowserHistoryStore.shared.clearHistory() } - Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…")) { - // Defer modal presentation until after AppKit finishes menu tracking. - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - } - splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: menuShortcut(for: .nextSidebarTab)) { activeTabManager.selectNextTab() } @@ -846,19 +814,6 @@ struct programaApp: App { SocketControlSettings.migrateMode(socketControlMode) } - /// Starts/stops the mobile companion bridge (M1) to match the persisted - /// mode -- mirrors `updateSocketController()`'s shape, but this is a - /// wholly separate on/off switch from Programa's Unix control socket - /// (see `MobileBridgeListener`). - private func updateMobileBridgeController() { - let mode = MobileBridgeSettings.mode(for: mobileBridgeMode) - if mode == .pairedDevicesOnly { - MobileBridgeListener.shared.start(tabManager: tabManager) - } else { - MobileBridgeListener.shared.stop() - } - } - private func menuShortcut(for action: KeyboardShortcutSettings.Action) -> StoredShortcut { let _ = keyboardShortcutSettingsObserver.revision return KeyboardShortcutSettings.shortcut(for: action) @@ -1147,7 +1102,6 @@ private let programaAuxiliaryWindowIdentifiers: Set<String> = [ "programa.browser-popup", "programa.settingsAboutTitlebarDebug", "programa.debugWindowControls", - "programa.browserImportHintDebug", "programa.sidebarDebug", "programa.menubarDebug", "programa.backgroundDebug", @@ -1704,7 +1658,6 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { enum SettingsNavigationTarget: String { case browser - case browserImport case keyboardShortcuts } diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 7df45086..333d024f 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -501,9 +501,6 @@ final class ProgramaSettingsFileStore { logInvalid("notifications.sound", sourcePath: sourcePath) } } - if let raw = jsonString(section["customSoundFilePath"]) { - snapshot.managedUserDefaults[NotificationSoundSettings.customFilePathKey] = .string(raw) - } if let raw = jsonString(section["command"]) { snapshot.managedUserDefaults[NotificationSoundSettings.customCommandKey] = .string(raw) } @@ -690,6 +687,9 @@ final class ProgramaSettingsFileStore { if let value = jsonBool(section["claudeCodeIntegration"]) { snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.hooksEnabledKey] = .bool(value) } + if let value = jsonBool(section["openBrowserWithAgentSplits"]) { + snapshot.managedUserDefaults[AgentBrowserSplitSettings.key] = .bool(value) + } if let raw = jsonString(section["claudeBinaryPath"]) { snapshot.managedUserDefaults[ClaudeCodeIntegrationSettings.customClaudePathKey] = .string(raw) } @@ -791,9 +791,6 @@ final class ProgramaSettingsFileStore { } else if section.keys.contains("insecureHttpHostsAllowedInEmbeddedBrowser") { logInvalid("browser.insecureHttpHostsAllowedInEmbeddedBrowser", sourcePath: sourcePath) } - if let value = jsonBool(section["showImportHintOnBlankTabs"]) { - snapshot.managedUserDefaults[BrowserImportHintSettings.showOnBlankTabsKey] = .bool(value) - } if let proxyRaw = section["proxy"] { guard let proxyDict = proxyRaw as? [String: Any] else { logInvalid("browser.proxy", sourcePath: sourcePath) @@ -1574,7 +1571,6 @@ final class ProgramaSettingsFileStore { "notifications": [ "showInMenuBar": MenuBarExtraSettings.defaultShowInMenuBar, "sound": NotificationSoundSettings.defaultValue, - "customSoundFilePath": NotificationSoundSettings.defaultCustomFilePath, "command": NotificationSoundSettings.defaultCustomCommand, ], ], @@ -1602,6 +1598,7 @@ final class ProgramaSettingsFileStore { "socketControlMode": SocketControlSettings.defaultMode.rawValue, "socketPassword": "", "claudeCodeIntegration": ClaudeCodeIntegrationSettings.defaultHooksEnabled, + "openBrowserWithAgentSplits": AgentBrowserSplitSettings.defaultValue, "claudeBinaryPath": "", "portBase": 9100, "portRange": 10, @@ -1622,7 +1619,6 @@ final class ProgramaSettingsFileStore { "hostsToOpenInEmbeddedBrowser": [String](), "urlsToAlwaysOpenExternally": [String](), "insecureHttpHostsAllowedInEmbeddedBrowser": BrowserInsecureHTTPSettings.defaultAllowlistPatterns, - "showImportHintOnBlankTabs": BrowserImportHintSettings.defaultShowOnBlankTabs, ], ], [ diff --git a/Sources/RemoteRelayZshBootstrap.swift b/Sources/RemoteRelayZshBootstrap.swift deleted file mode 100644 index 4a2cccc0..00000000 --- a/Sources/RemoteRelayZshBootstrap.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -struct RemoteRelayZshBootstrap { - let shellStateDir: String - - private var sharedHistoryLines: [String] { - [ - "if [ -z \"${HISTFILE:-}\" ] || [ \"$HISTFILE\" = \"\(shellStateDir)/.zsh_history\" ]; then export HISTFILE=\"$PROGRAMA_REAL_ZDOTDIR/.zsh_history\"; fi", - ] - } - - var zshEnvLines: [String] { - [ - "[ -f \"$PROGRAMA_REAL_ZDOTDIR/.zshenv\" ] && source \"$PROGRAMA_REAL_ZDOTDIR/.zshenv\"", - "if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export PROGRAMA_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi", - ] + sharedHistoryLines + [ - "export ZDOTDIR=\"\(shellStateDir)\"", - ] - } - - var zshProfileLines: [String] { - [ - "[ -f \"$PROGRAMA_REAL_ZDOTDIR/.zprofile\" ] && source \"$PROGRAMA_REAL_ZDOTDIR/.zprofile\"", - ] - } - - func zshRCLines(commonShellLines: [String]) -> [String] { - sharedHistoryLines + [ - "[ -f \"$PROGRAMA_REAL_ZDOTDIR/.zshrc\" ] && source \"$PROGRAMA_REAL_ZDOTDIR/.zshrc\"", - ] + commonShellLines - } - - var zshLoginLines: [String] { - [ - "[ -f \"$PROGRAMA_REAL_ZDOTDIR/.zlogin\" ] && source \"$PROGRAMA_REAL_ZDOTDIR/.zlogin\"", - ] - } -} diff --git a/Sources/RemoteSCPUpload.swift b/Sources/RemoteSCPUpload.swift deleted file mode 100644 index c99c69d9..00000000 --- a/Sources/RemoteSCPUpload.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -/// Uploads each item in `items` via `performUpload`, tracking which remote paths have -/// been recorded so far. If any upload throws (including cancellation surfaced by -/// `checkCancelled`), invokes `cleanup` with whatever remote paths were recorded before -/// the failure, then rethrows the original error. -/// -/// This is the shared control flow behind the two previously-independent -/// scp-upload-with-cancel-cleanup routines: the ad-hoc detected-SSH-session path -/// (`TerminalSSHSessionDetector.swift`) and the daemon-relay managed-workspace path -/// (`WorkspaceRemoteSession.swift`). The two differ in how a single file is transferred -/// (which executable/argument builder they use) and in whether they record a file's -/// remote destination before or after the transfer completes — both differences are -/// preserved by leaving them to `performUpload`, which decides when to call `record`. -/// Refs #92. -func performSCPUploadWithCancelCleanup<Item>( - items: [Item], - checkCancelled: () throws -> Void, - performUpload: (_ item: Item, _ record: (String) -> Void) throws -> Void, - cleanup: (_ uploadedRemotePaths: [String]) -> Void -) throws -> [String] { - guard !items.isEmpty else { return [] } - - var uploadedRemotePaths: [String] = [] - do { - for item in items { - try checkCancelled() - try performUpload(item) { uploadedRemotePaths.append($0) } - } - return uploadedRemotePaths - } catch { - cleanup(uploadedRemotePaths) - throw error - } -} diff --git a/Sources/RemoteSSHConnectionPolicy.swift b/Sources/RemoteSSHConnectionPolicy.swift deleted file mode 100644 index e5c2aab5..00000000 --- a/Sources/RemoteSSHConnectionPolicy.swift +++ /dev/null @@ -1,134 +0,0 @@ -import Foundation - -/// Shared building blocks for constructing `ssh`/`scp` argument lists. -/// -/// Consolidates the connection-policy flags (keepalive timeouts, the -/// `StrictHostKeyChecking` default, the `BatchMode`/`ControlMaster` pairing) and the -/// `-o key=value` option-parsing helpers that were previously copy-pasted across -/// `WorkspaceRemoteSession.swift`, `WorkspaceRemoteDaemon.swift`, and -/// `TerminalSSHSessionDetector.swift`. Each call site still assembles its own argument -/// list (they differ in scp/ssh flags, jump-host/proxy handling, and port-flag -/// spelling), but the identical policy fragments now have one definition. Refs #92. -enum RemoteSSHConnectionPolicy { - /// `-o ConnectTimeout=6 -o ServerAliveInterval=20 -o ServerAliveCountMax=2` - static let keepaliveArguments: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - ] - - /// `-o BatchMode=yes -o ControlMaster=no`, for non-interactive/background invocations. - static let batchModeArguments: [String] = [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] - - /// `-o StrictHostKeyChecking=accept-new`, appended unless the caller's own `-o` - /// options already set `StrictHostKeyChecking` explicitly. - static func strictHostKeyCheckingArguments(unlessSetIn options: [String]) -> [String] { - hasOptionKey(options, key: "StrictHostKeyChecking") ? [] : ["-o", "StrictHostKeyChecking=accept-new"] - } - - static func hasOptionKey(_ options: [String], key: String) -> Bool { - let loweredKey = key.lowercased() - return options.contains { optionKey($0) == loweredKey } - } - - static func normalizedOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - } - } - - private static let backgroundExcludedOptionKeys: Set<String> = [ - "controlmaster", - "controlpersist", - ] - - /// Strips `ControlMaster`/`ControlPersist` so a batch invocation can't negotiate (or - /// collide with) an interactive control-master. - static func backgroundOptions(_ options: [String]) -> [String] { - normalizedOptions(options).filter { option in - guard let key = optionKey(option) else { return false } - return !backgroundExcludedOptionKeys.contains(key) - } - } - - /// Looks up the value of a named `-o key=value` option within a list of raw options. - static func optionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in normalizedOptions(options) { - let parts = option.split( - maxSplits: 1, - omittingEmptySubsequences: true, - whereSeparator: { $0 == "=" || $0.isWhitespace } - ) - guard parts.count == 2, parts[0].lowercased() == loweredKey else { continue } - let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { return value } - } - return nil - } - - static func optionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - /// POSIX single-quote a string for interpolation into a `sh -c '...'` command. - static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - - /// Rewrites a destination for `scp`'s combined `host:path` argument syntax by - /// bracketing a bare IPv6 literal host (`user@2001:db8::1` -> `user@[2001:db8::1]`). - /// - /// `ssh` takes the destination as its own argument, so a bare IPv6 literal like - /// `2001:db8::1` is unambiguous there (see `CLI+SSH.swift`'s `normalizeSSHDestination`, - /// which instead *strips* brackets for that call). `scp` glues the destination and the - /// remote path together with a colon (`host:path`), so an un-bracketed IPv6 host's own - /// colons collide with that separator and scp misparses the path. Only bare/unbracketed - /// IPv6 hosts are rewritten — `user@host`, hostnames, IPv4 literals, and already-bracketed - /// hosts pass through unchanged (#4948 follow-up: the ssh-only fix in `CLI+SSH.swift` - /// didn't cover our scp call sites). - static func scpRemoteDestination(_ destination: String) -> String { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDestination.isEmpty else { return destination } - - let parts = trimmedDestination.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) - let userPart: String? - let hostPart: String - if parts.count == 2 { - userPart = String(parts[0]) - hostPart = String(parts[1]) - } else { - userPart = nil - hostPart = trimmedDestination - } - - guard shouldBracketIPv6LiteralForSCP(hostPart) else { - return trimmedDestination - } - - let bracketedHost = "[\(hostPart)]" - if let userPart { - return "\(userPart)@\(bracketedHost)" - } - return bracketedHost - } - - private static func shouldBracketIPv6LiteralForSCP(_ host: String) -> Bool { - let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmedHost.isEmpty && - trimmedHost.contains(":") && - !trimmedHost.hasPrefix("[") && - !trimmedHost.hasSuffix("]") - } -} diff --git a/Sources/SessionEscrow.swift b/Sources/SessionEscrow.swift index 6ed10885..c944ffa5 100644 --- a/Sources/SessionEscrow.swift +++ b/Sources/SessionEscrow.swift @@ -18,9 +18,7 @@ import Bonsplit /// protocol" and "Drain/retrieve coordination" below. /// /// ## Holder process choice -/// Nothing is always-running in the normal desktop case -- -/// `daemon/remote/cmd/programad-remote` only bootstraps for the SSH remote -/// workflow, it is not resident otherwise. Rather than add a second Xcode +/// Nothing is always-running in the normal desktop case. Rather than add a second Xcode /// target/binary (a new `PBXNativeTarget`, code signing, and an /// embed-helper build phase -- out of scope for one slice), the holder is /// the SAME app binary launched in a hidden mode: diff --git a/Sources/SettingsModels.swift b/Sources/SettingsModels.swift index 19d93f7d..77474f56 100644 --- a/Sources/SettingsModels.swift +++ b/Sources/SettingsModels.swift @@ -95,6 +95,20 @@ enum QuitWarningSettings { } } +enum AgentBrowserSplitSettings { + static let key = "openBrowserWithAgentSplits" + static let defaultValue = false + private static let flag = UserDefaultsFlag(key: key, defaultValue: defaultValue) + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + flag.isEnabled(defaults: defaults) + } + + static func setEnabled(_ isEnabled: Bool, defaults: UserDefaults = .standard) { + flag.setEnabled(isEnabled, defaults: defaults) + } +} + enum ScrollbackPersistenceSettings { static let persistScrollbackKey = "sessionPersistScrollback" static let defaultPersistScrollback = true @@ -224,7 +238,6 @@ enum SettingsTab: String, CaseIterable, Identifiable { case general case appearance case automation - case phone case browser case shortcuts @@ -235,7 +248,6 @@ enum SettingsTab: String, CaseIterable, Identifiable { case .general: String(localized: "settings.tab.general", defaultValue: "General") case .appearance: String(localized: "settings.tab.appearance", defaultValue: "Appearance") case .automation: String(localized: "settings.tab.automation", defaultValue: "Automation") - case .phone: String(localized: "settings.tab.phone", defaultValue: "Phone") case .browser: String(localized: "settings.tab.browser", defaultValue: "Browser") case .shortcuts: String(localized: "settings.tab.shortcuts", defaultValue: "Shortcuts") } @@ -245,7 +257,7 @@ enum SettingsTab: String, CaseIterable, Identifiable { /// still lands on the right content now that it is not all one scroll. static func owning(_ target: SettingsNavigationTarget) -> SettingsTab { switch target { - case .browser, .browserImport: .browser + case .browser: .browser case .keyboardShortcuts: .shortcuts } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 0ec6008d..47a386ab 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -42,7 +42,6 @@ struct SettingsView: View { @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue - @AppStorage(MobileBridgeSettings.appStorageKey) private var mobileBridgeMode = MobileBridgeSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @AppStorage(ClaudeCodeIntegrationSettings.customClaudePathKey) @@ -57,8 +56,6 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue - @AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs - @AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInProgramaBrowserKey) private var openTerminalLinksInProgramaBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInProgramaBrowser @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInProgramaBrowserKey) private var interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInProgramaBrowserValue() @@ -67,13 +64,12 @@ struct SettingsView: View { private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationSoundSettings.key) private var notificationSound = NotificationSoundSettings.defaultValue - @AppStorage(NotificationSoundSettings.customFilePathKey) - private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar @AppStorage(LongCommandNotificationSettings.thresholdSecondsKey) private var longCommandThresholdSeconds = LongCommandNotificationSettings.defaultThresholdSeconds @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(AgentBrowserSplitSettings.key) private var openBrowserWithAgentSplits = AgentBrowserSplitSettings.defaultValue @AppStorage(ScrollbackPersistenceSettings.persistScrollbackKey) private var sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces @@ -104,24 +100,11 @@ struct SettingsView: View { @State private var showOpenAccessConfirmation = false @State private var pendingOpenAccessMode: SocketControlMode? @State private var browserHistoryEntryCount: Int = 0 - @State private var detectedImportBrowsers: [InstalledBrowserCandidate] = [] @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText @State private var socketPasswordDraft = "" @State private var socketPasswordStatusMessage: String? @State private var socketPasswordStatusIsError = false - @State private var notificationCustomSoundStatusMessage: String? - @State private var notificationCustomSoundStatusIsError = false - @State private var showNotificationCustomSoundErrorAlert = false - @State private var notificationCustomSoundErrorAlertMessage = "" @State private var trustedDirectoriesDraft: String = ProgramaDirectoryTrust.shared.allTrustedPaths.joined(separator: "\n") - @State private var mobileBridgePairedDevices: [MobileBridgeTrustedDevice] = [] - @State private var mobileBridgePairingTicket: String? - @State private var mobileBridgePairingToken: String? - @State private var mobileBridgePairingExpiresAt: Date? - @State private var mobileBridgePairingErrorMessage: String? - @State private var mobileBridgeRevocationErrorMessage: String? - @State private var mobileBridgeRevocationsInFlight: Set<String> = [] - @State private var isPairingMobileBridgeDevice = false private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -186,25 +169,6 @@ struct SettingsView: View { ) } - private var browserImportHintPresentation: BrowserImportHintPresentation { - BrowserImportHintPresentation( - showOnBlankTabs: showBrowserImportHintOnBlankTabs, - isDismissed: isBrowserImportHintDismissed - ) - } - - private var browserImportHintVisibilityBinding: Binding<Bool> { - Binding( - get: { showBrowserImportHintOnBlankTabs }, - set: { newValue in - showBrowserImportHintOnBlankTabs = newValue - if newValue { - isBrowserImportHintDismissed = false - } - } - ) - } - private var socketModeSelection: Binding<String> { Binding( get: { socketControlMode }, @@ -251,15 +215,6 @@ struct SettingsView: View { } } - private var browserImportHintSettingsNote: String { - switch browserImportHintPresentation.settingsStatus { - case .visible: - return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.") - case .hidden: - return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.") - } - } - private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist } @@ -272,29 +227,8 @@ struct SettingsView: View { ProgramaDirectoryTrust.shared.replaceAll(with: paths) } - private var hasCustomNotificationSoundFilePath: Bool { - !notificationSoundCustomFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private var notificationSoundCustomFileDisplayName: String { - guard hasCustomNotificationSoundFilePath else { - return String( - localized: "settings.notifications.sound.custom.file.none", - defaultValue: "No file selected" - ) - } - return URL(fileURLWithPath: notificationSoundCustomFilePath).lastPathComponent - } - private var canPreviewNotificationSound: Bool { - switch notificationSound { - case "none": - return false - case NotificationSoundSettings.customFileValue: - return hasCustomNotificationSoundFilePath - default: - return true - } + notificationSound != "none" } private var notificationPermissionStatusText: String { @@ -343,122 +277,9 @@ struct SettingsView: View { } private func previewNotificationSound() { - if notificationSound == NotificationSoundSettings.customFileValue { - NotificationSoundSettings.playCustomFileSound(path: notificationSoundCustomFilePath) - return - } NotificationSoundSettings.previewSound(value: notificationSound) } - private func notificationCustomSoundIssueMessage(_ issue: NotificationSoundSettings.CustomSoundPreparationIssue) -> String { - switch issue { - case .emptyPath: - return String( - localized: "settings.notifications.sound.custom.status.empty", - defaultValue: "Choose a custom audio file first." - ) - case .missingFile(let path): - let fileName = URL(fileURLWithPath: path).lastPathComponent - return String( - localized: "settings.notifications.sound.custom.status.missingFilePrefix", - defaultValue: "File not found: " - ) + fileName - case .missingFileExtension(let path): - let fileName = URL(fileURLWithPath: path).lastPathComponent - return String( - localized: "settings.notifications.sound.custom.status.missingExtensionPrefix", - defaultValue: "File needs an extension: " - ) + fileName - case .stagingFailed(_, let details): - let prefix = String( - localized: "settings.notifications.sound.custom.status.prepareFailed", - defaultValue: "Could not prepare this file for notifications. Try WAV, AIFF, or CAF." - ) - return "\(prefix) (\(details))" - } - } - - private func notificationCustomSoundReadyStatusMessage(for path: String) -> String { - let sourceExtension = URL(fileURLWithPath: path).pathExtension - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let stagedExtension = NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: sourceExtension) - if !sourceExtension.isEmpty, stagedExtension != sourceExtension { - return String( - localized: "settings.notifications.sound.custom.status.readyConverted", - defaultValue: "Prepared for notifications (converted to CAF)." - ) - } - return String( - localized: "settings.notifications.sound.custom.status.ready", - defaultValue: "Ready for notifications." - ) - } - - private func refreshNotificationCustomSoundStatus(showAlertOnFailure: Bool = false) { - guard notificationSound == NotificationSoundSettings.customFileValue else { - notificationCustomSoundStatusMessage = nil - notificationCustomSoundStatusIsError = false - return - } - let pathSnapshot = notificationSoundCustomFilePath - DispatchQueue.global(qos: .userInitiated).async { - let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: pathSnapshot) - DispatchQueue.main.async { - guard notificationSound == NotificationSoundSettings.customFileValue else { - notificationCustomSoundStatusMessage = nil - notificationCustomSoundStatusIsError = false - return - } - guard notificationSoundCustomFilePath == pathSnapshot else { return } - switch result { - case .success: - notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: pathSnapshot) - notificationCustomSoundStatusIsError = false - case .failure(let issue): - let message = notificationCustomSoundIssueMessage(issue) - notificationCustomSoundStatusMessage = message - notificationCustomSoundStatusIsError = true - if showAlertOnFailure { - notificationCustomSoundErrorAlertMessage = message - showNotificationCustomSoundErrorAlert = true - } - } - } - } - } - - private func chooseNotificationSoundFile() { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.audio] - panel.title = String( - localized: "settings.notifications.sound.custom.choose.title", - defaultValue: "Choose Notification Sound" - ) - panel.prompt = String( - localized: "settings.notifications.sound.custom.choose.prompt", - defaultValue: "Choose" - ) - guard panel.runModal() == .OK, let url = panel.url else { return } - let selectedPath = url.path - switch NotificationSoundSettings.prepareCustomFileForNotifications(path: selectedPath) { - case .success: - notificationSoundCustomFilePath = selectedPath - notificationSound = NotificationSoundSettings.customFileValue - notificationCustomSoundStatusMessage = notificationCustomSoundReadyStatusMessage(for: selectedPath) - notificationCustomSoundStatusIsError = false - previewNotificationSound() - case .failure(let issue): - let message = notificationCustomSoundIssueMessage(issue) - notificationCustomSoundErrorAlertMessage = message - showNotificationCustomSoundErrorAlert = true - refreshNotificationCustomSoundStatus() - } - } - private func handleNotificationPermissionAction() { let state = notificationStore.authorizationState.statusLabel #if DEBUG @@ -523,8 +344,6 @@ struct SettingsView: View { agentsSection portsSection customCommandsSection - case .phone: - phoneSection case .browser: browsingSection browserLinksSection @@ -633,15 +452,6 @@ struct SettingsView: View { browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist - refreshDetectedImportBrowsers() - refreshNotificationCustomSoundStatus() - Task { await refreshMobileBridgePairedDevices() } - } - .onChange(of: notificationSound) { _, _ in - refreshNotificationCustomSoundStatus() - } - .onChange(of: notificationSoundCustomFilePath) { _, _ in - refreshNotificationCustomSoundStatus() } .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in // Keep draft in sync with external changes unless the user has local unsaved edits. @@ -690,17 +500,6 @@ struct SettingsView: View { } message: { Text(String(localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk.")) } - .alert( - String( - localized: "settings.notifications.sound.custom.error.title", - defaultValue: "Custom Notification Sound Error" - ), - isPresented: $showNotificationCustomSoundErrorAlert - ) { - Button(String(localized: "common.ok", defaultValue: "OK"), role: .cancel) {} - } message: { - Text(notificationCustomSoundErrorAlertMessage) - } } } @@ -836,63 +635,22 @@ struct SettingsView: View { subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."), controlWidth: notificationSoundControlWidth ) { - VStack(alignment: .trailing, spacing: 6) { - HStack(spacing: 6) { - Picker("", selection: $notificationSound) { - ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in - Text(sound.label).tag(sound.value) - } - } - .labelsHidden() - Button { - previewNotificationSound() - } label: { - Image(systemName: "play.fill") - .symbolRasterSize(9) + HStack(spacing: 6) { + Picker("", selection: $notificationSound) { + ForEach(NotificationSoundSettings.systemSounds, id: \.value) { sound in + Text(sound.label).tag(sound.value) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!canPreviewNotificationSound) } - - if notificationSound == NotificationSoundSettings.customFileValue { - HStack(spacing: 6) { - Text(notificationSoundCustomFileDisplayName) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 170, alignment: .trailing) - Button( - String( - localized: "settings.notifications.sound.custom.choose.button", - defaultValue: "Choose..." - ) - ) { - chooseNotificationSoundFile() - } - .controlSize(.small) - Button( - String( - localized: "settings.notifications.sound.custom.clear.button", - defaultValue: "Clear" - ) - ) { - notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath - refreshNotificationCustomSoundStatus() - } - .controlSize(.small) - .disabled(!hasCustomNotificationSoundFilePath) - } - if let notificationCustomSoundStatusMessage { - Text(notificationCustomSoundStatusMessage) - .font(.system(size: 11)) - .foregroundStyle(notificationCustomSoundStatusIsError ? Color.red : Color.secondary) - .lineLimit(2) - .multilineTextAlignment(.trailing) - .frame(width: 260, alignment: .trailing) - } + .labelsHidden() + Button { + previewNotificationSound() + } label: { + Image(systemName: "play.fill") + .symbolRasterSize(9) } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canPreviewNotificationSound) } .frame(maxWidth: .infinity, alignment: .trailing) } @@ -1241,6 +999,20 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardNote(String(localized: "settings.automation.claudeCode.note", defaultValue: "When enabled, Programa wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")) + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.agents.browserSplit", defaultValue: "Open a browser beside new agents"), + subtitle: openBrowserWithAgentSplits + ? String(localized: "settings.agents.browserSplit.subtitleOn", defaultValue: "New agent workspaces get a browser split next to the terminal.") + : String(localized: "settings.agents.browserSplit.subtitleOff", defaultValue: "New agent workspaces open with a terminal only.") + ) { + Toggle("", isOn: $openBrowserWithAgentSplits) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsAgentBrowserSplitToggle") + } } SettingsCard { @@ -1330,238 +1102,6 @@ struct SettingsView: View { ) } - private var selectedMobileBridgeMode: MobileBridgeMode { - MobileBridgeSettings.mode(for: mobileBridgeMode) - } - - private var mobileBridgeModeSelection: Binding<String> { - Binding( - get: { mobileBridgeMode }, - set: { mobileBridgeMode = $0 } - ) - } - - private func beginMobileBridgePairing() { - isPairingMobileBridgeDevice = true - mobileBridgePairingErrorMessage = nil - Task { - if let pairing = await MobileBridgeListener.shared.beginPairing() { - mobileBridgePairingTicket = pairing.ticket - mobileBridgePairingToken = pairing.token - mobileBridgePairingExpiresAt = pairing.expiresAt - } else { - mobileBridgePairingTicket = nil - mobileBridgePairingToken = nil - mobileBridgePairingExpiresAt = nil - mobileBridgePairingErrorMessage = String( - localized: "settings.phone.pair.error", - defaultValue: "Could not start pairing. The phone companion may still be connecting — try again in a moment." - ) - } - isPairingMobileBridgeDevice = false - } - } - - private func revokeMobileBridgeDevice(_ device: MobileBridgeTrustedDevice) { - guard mobileBridgeRevocationsInFlight.insert(device.endpointId).inserted else { return } - mobileBridgeRevocationErrorMessage = nil - - Task { - defer { mobileBridgeRevocationsInFlight.remove(device.endpointId) } - let outcome = await MobileBridgeListener.shared.revoke(endpointId: device.endpointId) - await refreshMobileBridgePairedDevices() - if outcome == .persistenceFailed { - mobileBridgeRevocationErrorMessage = String( - localized: "settings.phone.devices.revokeFailed", - defaultValue: "Could not remove this device. Its connection remains active. Try again." - ) - } - } - } - - private func refreshMobileBridgePairedDevices() async { - mobileBridgePairedDevices = await MobileBridgeTrustedDeviceStore.shared.allDevices() - } - - private func mobileBridgePairedOnSubtitle(_ device: MobileBridgeTrustedDevice) -> String { - let formattedDate = device.pairedAt.formatted(date: .abbreviated, time: .shortened) - return String(localized: "settings.phone.devices.pairedOn", defaultValue: "Paired \(formattedDate)") - } - - /// Renders `string` as a fixed-size QR code using CoreImage's built-in - /// generator (no third-party dependency). Regenerated on demand rather - /// than cached in `@State` -- this only runs while the pairing card is - /// visible in Settings, not on any hot path. - private func mobileBridgeQRImage(for string: String) -> NSImage? { - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(string.utf8) - filter.correctionLevel = "M" - guard let outputImage = filter.outputImage else { return nil } - - let targetSize: CGFloat = 512 - let scale = targetSize / outputImage.extent.width - let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) - - let rep = NSCIImageRep(ciImage: scaled) - let image = NSImage(size: rep.size) - image.addRepresentation(rep) - return image - } - - private func mobileBridgePairingRemainingSeconds(at date: Date) -> Int? { - guard let expiresAt = mobileBridgePairingExpiresAt else { return nil } - return max(0, Int(expiresAt.timeIntervalSince(date).rounded(.up))) - } - - private func mobileBridgePairingIsExpired(at date: Date) -> Bool { - (mobileBridgePairingRemainingSeconds(at: date) ?? 0) <= 0 - } - - private func mobileBridgePairingCountdownLabel(at date: Date) -> String { - guard let remaining = mobileBridgePairingRemainingSeconds(at: date) else { return "" } - if remaining <= 0 { - return String(localized: "settings.phone.pair.expired", defaultValue: "Expired. Start a new pairing.") - } - let timeString = String(format: "%d:%02d", remaining / 60, remaining % 60) - return String(localized: "settings.phone.pair.expiresIn", defaultValue: "Single use. Expires in \(timeString).") - } - - @ViewBuilder - private var phoneSection: some View { - SettingsSectionHeader(title: String(localized: "settings.section.phone", defaultValue: "Phone")) - SettingsCard { - SettingsPickerRow( - String(localized: "settings.phone.mode", defaultValue: "Mobile Companion"), - subtitle: selectedMobileBridgeMode == .pairedDevicesOnly - ? String(localized: "settings.phone.mode.subtitleOn", defaultValue: "Paired iPhones can reach this Mac over a private peer-to-peer connection.") - : String(localized: "settings.phone.mode.subtitleOff", defaultValue: "The phone companion is off. No device can connect."), - controlWidth: pickerColumnWidth, - selection: mobileBridgeModeSelection, - accessibilityId: "MobileBridgeModePicker" - ) { - ForEach(MobileBridgeMode.uiCases) { mode in - Text(mode.displayName).tag(mode.rawValue) - } - } - - if selectedMobileBridgeMode == .pairedDevicesOnly { - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.phone.pair.title", defaultValue: "Pair a Device"), - subtitle: String(localized: "settings.phone.pair.subtitleV2", defaultValue: "Opens a single-use, 5-minute pairing window. Scan the QR code with the Programa iOS app, or copy the code it shows.") - ) { - Button(String(localized: "settings.phone.pair.button", defaultValue: "Pair a Device…")) { - beginMobileBridgePairing() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(isPairingMobileBridgeDevice) - .accessibilityIdentifier("MobileBridgePairButton") - } - - if let ticket = mobileBridgePairingTicket, let token = mobileBridgePairingToken, - let pairingURL = MobileBridgePairingCode.makeURL(ticket: ticket, token: token) { - SettingsCardDivider() - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .top, spacing: 14) { - if let qrImage = mobileBridgeQRImage(for: pairingURL.absoluteString) { - Image(nsImage: qrImage) - .interpolation(.none) - .resizable() - .frame(width: 168, height: 168) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .accessibilityIdentifier("MobileBridgePairingQRCode") - } - - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "settings.phone.pair.scanLabel", defaultValue: "Scan this with the Programa iOS app's \u{201c}Scan QR Code\u{201d} button.")) - .font(.system(size: 12, weight: .semibold)) - - Text(pairingURL.absoluteString) - .font(.system(size: 10, design: .monospaced)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - - HStack(spacing: 10) { - Button(String(localized: "settings.phone.pair.copy", defaultValue: "Copy")) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(pairingURL.absoluteString, forType: .string) - } - .buttonStyle(.bordered) - .controlSize(.small) - - TimelineView(.periodic(from: mobileBridgePairingExpiresAt ?? Date(), by: 1)) { context in - Text(mobileBridgePairingCountdownLabel(at: context.date)) - .font(.caption) - .foregroundStyle(mobileBridgePairingIsExpired(at: context.date) ? .red : .secondary) - } - } - } - } - - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - } - - if let mobileBridgePairingErrorMessage { - SettingsCardDivider() - Text(mobileBridgePairingErrorMessage) - .font(.caption) - .foregroundStyle(.red) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } - - SettingsCardDivider() - - SettingsCardNote(String(localized: "settings.phone.note", defaultValue: "Programa runs no server. Your phone connects directly to this Mac over a private peer-to-peer link, and only a small allow-list of methods can be sent over it.")) - } - } - - if selectedMobileBridgeMode == .pairedDevicesOnly { - SettingsCard { - SettingsCardRow(String(localized: "settings.phone.devices.title", defaultValue: "Paired Devices")) { - EmptyView() - } - - if mobileBridgePairedDevices.isEmpty { - SettingsCardDivider() - SettingsCardNote(String(localized: "settings.phone.devices.empty", defaultValue: "No devices paired yet.")) - } else { - ForEach(mobileBridgePairedDevices) { device in - SettingsCardDivider() - SettingsCardRow(device.label, subtitle: mobileBridgePairedOnSubtitle(device)) { - Button(String(localized: "settings.phone.devices.revoke", defaultValue: "Remove")) { - revokeMobileBridgeDevice(device) - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(mobileBridgeRevocationsInFlight.contains(device.endpointId)) - } - } - } - - if let mobileBridgeRevocationErrorMessage { - SettingsCardDivider() - Text(mobileBridgeRevocationErrorMessage) - .font(.caption) - .foregroundStyle(.red) - .padding(.horizontal, 14) - .padding(.vertical, 8) - } - } - } - } - @ViewBuilder private var customCommandsSection: some View { SettingsSectionHeader(title: String(localized: "settings.section.customCommands", defaultValue: "Custom Commands")) @@ -1787,53 +1327,6 @@ struct SettingsView: View { private var browserDataSection: some View { SettingsSectionHeader(title: String(localized: "settings.section.browserData", defaultValue: "Data")) SettingsCard { - VStack(alignment: .leading, spacing: 12) { - // A mock of the blank-tab import hint used to be rendered here, - // reusing the hint's own strings -- including its footnote saying - // "You can always find this in Settings > Browser", shown inside - // Settings. The buttons below do the same job without restating - // the hint. The real card still lives in BrowserToolbarViews. - Text(String(localized: "settings.browser.import", defaultValue: "Import Browser Data")) - .font(.system(size: 13, weight: .semibold)) - - HStack(spacing: 8) { - Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) { - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - refreshDetectedImportBrowsers() - } - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsBrowserImportChooseButton") - - Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) { - refreshDetectedImportBrowsers() - } - .buttonStyle(.bordered) - .controlSize(.small) - } - .accessibilityIdentifier("SettingsBrowserImportActions") - - Toggle( - String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"), - isOn: browserImportHintVisibilityBinding - ) - .controlSize(.small) - .accessibilityIdentifier("SettingsBrowserImportHintToggle") - - Text(browserImportHintSettingsNote) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .id(SettingsNavigationTarget.browserImport) - .accessibilityIdentifier("SettingsBrowserImportSection") - .padding(.horizontal, 14) - .padding(.vertical, 10) - - SettingsCardDivider() - SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) { Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) { showClearBrowserHistoryConfirmation = true @@ -1933,8 +1426,6 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue - showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs - isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed openTerminalLinksInProgramaBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInProgramaBrowser interceptTerminalOpenCommandInProgramaBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInProgramaBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @@ -1942,14 +1433,10 @@ struct SettingsView: View { browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationSound = NotificationSoundSettings.defaultValue - notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath - notificationCustomSoundStatusMessage = nil - notificationCustomSoundStatusIsError = false - showNotificationCustomSoundErrorAlert = false - notificationCustomSoundErrorAlertMessage = "" notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + openBrowserWithAgentSplits = AgentBrowserSplitSettings.defaultValue sessionPersistScrollback = ScrollbackPersistenceSettings.defaultPersistScrollback commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces ShortcutHintDebugSettings.resetVisibilityDefaults() @@ -1973,7 +1460,6 @@ struct SettingsView: View { socketPasswordDraft = "" socketPasswordStatusMessage = nil socketPasswordStatusIsError = false - refreshDetectedImportBrowsers() KeyboardShortcutSettings.resetAll() WorkspaceTabColorSettings.reset() WorkspaceTabColorSettings.resetRememberedFolderColors() @@ -1984,9 +1470,6 @@ struct SettingsView: View { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } - private func refreshDetectedImportBrowsers() { - detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers() - } } @MainActor diff --git a/Sources/SidebarRemoteErrorCopy.swift b/Sources/SidebarRemoteErrorCopy.swift deleted file mode 100644 index 577400c4..00000000 --- a/Sources/SidebarRemoteErrorCopy.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -struct SidebarRemoteErrorCopyEntry: Equatable { - let workspaceTitle: String - let target: String - let detail: String -} - -enum SidebarRemoteErrorCopySupport { - static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { - guard !entries.isEmpty else { return nil } - if entries.count == 1 { - return String(localized: "contextMenu.copyError", defaultValue: "Copy Error") - } - return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors") - } - - static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { - guard !entries.isEmpty else { return nil } - if entries.count == 1, let entry = entries.first { - return String.localizedStringWithFormat( - String(localized: "clipboard.sshError.single", defaultValue: "SSH error (%@): %@"), - entry.target, - entry.detail - ) - } - - return entries.enumerated().map { index, entry in - String.localizedStringWithFormat( - String(localized: "clipboard.sshError.item", defaultValue: "%lld. %@ (%@): %@"), - Int64(index + 1), - entry.workspaceTitle, - entry.target, - entry.detail - ) - }.joined(separator: "\n") - } -} diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index cb8c44db..4c4c5d8e 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -114,7 +114,6 @@ private struct SidebarUsageButton: View { } private enum SidebarHelpMenuAction { - case importBrowserData case keyboardShortcuts case docs case changelog @@ -196,12 +195,6 @@ private struct SidebarHelpMenuButton: View { accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", isExternalLink: false ) - helpOptionButton( - title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), - action: .importBrowserData, - accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", - isExternalLink: false - ) if docsURL != nil { helpOptionButton( title: String(localized: "about.docs", defaultValue: "Docs"), @@ -326,11 +319,6 @@ private struct SidebarHelpMenuButton: View { private func perform(_ action: SidebarHelpMenuAction) { switch action { - case .importBrowserData: - isPopoverPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - } case .keyboardShortcuts: isPopoverPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 37b68897..73da3314 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -72,9 +72,6 @@ struct TabItemView: View, Equatable { lhs.rowSpacing == rhs.rowSpacing && lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && - lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && - lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && lhs.settings == rhs.settings && lhs.showsWorktreeBadge == rhs.showsWorktreeBadge && lhs.isWorktreeFolder == rhs.isWorktreeFolder && @@ -113,9 +110,6 @@ struct TabItemView: View, Equatable { @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? let contextMenuWorkspaceIds: [UUID] - let remoteContextMenuWorkspaceIds: [UUID] - let allRemoteContextMenuTargetsConnecting: Bool - let allRemoteContextMenuTargetsDisconnected: Bool let settings: SidebarTabItemSettingsSnapshot /// Set at creation time by `worktree.create`/`worktree.open` (`Workspace.worktreeParentWorkspaceId`). /// Precomputed by the caller (VerticalTabsSidebar) and included in `==` -- see the @@ -167,10 +161,6 @@ struct TabItemView: View, Equatable { settings.showsGitBranchIcon } - private var sidebarShowSSH: Bool { - settings.showsSSH - } - private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { settings.activeTabIndicatorStyle } @@ -323,85 +313,6 @@ struct TabItemView: View, Equatable { ) } - private var remoteWorkspaceSidebarText: String? { - guard tab.hasActiveRemoteTerminalSessions else { return nil } - let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedTarget, !trimmedTarget.isEmpty { - return trimmedTarget - } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") - } - - private var copyableSidebarSSHError: String? { - let fallbackTarget = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: trimmedDetail - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - if let statusValue = tab.statusEntries["remote.error"]?.value - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusValue.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: statusValue - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - return nil - } - - private var remoteConnectionStatusText: String { - switch tab.remoteConnectionState { - case .connected: - return String(localized: "remote.status.connected", defaultValue: "Connected") - case .connecting: - return String(localized: "remote.status.connecting", defaultValue: "Connecting") - case .error: - return String(localized: "remote.status.error", defaultValue: "Error") - case .disconnected: - return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") - } - } - - @ViewBuilder - private var remoteWorkspaceSection: some View { - if sidebarShowSSH, let remoteWorkspaceSidebarText { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - Text(remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.58)) - .lineLimit(1) - } - } - .padding(.top, latestNotificationText == nil ? 1 : 2) - .safeHelp(remoteStateHelpText) - } - } - - private func copyTextToPasteboard(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { settings.visibleAuxiliaryDetails } @@ -589,8 +500,6 @@ struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } - remoteWorkspaceSection - if detailVisibility.showsMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() @@ -923,13 +832,6 @@ struct TabItemView: View, Equatable { isMulti ? multi : single } - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - // Isolates the workspace-color submenu from the churning per-row // observation tick (`workspaceObservationGeneration`, bumped as often as // every 40ms while workspace telemetry is updating -- see the @@ -1008,14 +910,6 @@ struct TabItemView: View, Equatable { let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned - let reconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), - single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), - isMulti: isMulti) - let disconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), - single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), - isMulti: isMulti) let pinLabel = shouldPin ? contextMenuLabel( multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), @@ -1098,7 +992,7 @@ struct TabItemView: View, Equatable { } } - if !isMulti, !tab.isRemoteWorkspace, !showsWorktreeBadge { + if !isMulti, !showsWorktreeBadge { Divider() if isWorktreeFolder { @@ -1129,24 +1023,6 @@ struct TabItemView: View, Equatable { } } - if !remoteContextMenuWorkspaceIds.isEmpty { - Divider() - - Button(reconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.reconnectRemoteConnection() - } - } - .disabled(allRemoteContextMenuTargetsConnecting) - - Button(disconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(allRemoteContextMenuTargetsDisconnected) - } - WorkspaceColorMenu( hasCustomColor: tab.customColor != nil, palette: tabColorPalette, @@ -1158,12 +1034,6 @@ struct TabItemView: View, Equatable { ) .equatable() - if let copyableSidebarSSHError { - Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { - copyTextToPasteboard(copyableSidebarSSHError) - } - } - Divider() Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { @@ -1441,62 +1311,6 @@ struct TabItemView: View, Equatable { } } - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return String( - format: String( - localized: "sidebar.remote.help.connected", - defaultValue: "SSH connected to %@" - ), - locale: .current, - target - ) - case .connecting: - return String( - format: String( - localized: "sidebar.remote.help.connecting", - defaultValue: "SSH connecting to %@" - ), - locale: .current, - target - ) - case .error: - if let detail, !detail.isEmpty { - return String( - format: String( - localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "SSH error for %@: %@" - ), - locale: .current, - target, - detail - ) - } - return String( - format: String( - localized: "sidebar.remote.help.error", - defaultValue: "SSH error for %@" - ), - locale: .current, - target - ) - case .disconnected: - return String( - format: String( - localized: "sidebar.remote.help.disconnected", - defaultValue: "SSH disconnected from %@" - ), - locale: .current, - target - ) - } - } private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { guard let app = AppDelegate.shared else { return } let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } diff --git a/Sources/TabManager+Browser.swift b/Sources/TabManager+Browser.swift index 2c10eefe..c9320dcc 100644 --- a/Sources/TabManager+Browser.swift +++ b/Sources/TabManager+Browser.swift @@ -44,6 +44,29 @@ extension TabManager { )?.id } + /// Opens a browser split to the right of `workspace`'s focused terminal when the + /// "open a browser beside new agents" setting is on. No-op (returns nil) when the + /// setting is off or the workspace has no focused terminal. Never steals focus: the + /// split is created with `focus: false`, which leaves the terminal focused. + @discardableResult + func openCompanionBrowserSplitIfEnabled( + for workspace: Workspace, + defaults: UserDefaults = .standard + ) -> UUID? { + guard AgentBrowserSplitSettings.isEnabled(defaults: defaults), + let terminal = workspace.focusedTerminalPanel else { + return nil + } + return workspace.newBrowserSplit( + from: terminal.id, + orientation: SplitDirection.right.orientation, + insertFirst: SplitDirection.right.insertFirst, + url: nil, + preferredProfileID: nil, + focus: false + )?.id + } + /// Get a browser panel by ID func browserPanel(tabId: UUID, panelId: UUID) -> BrowserPanel? { guard let tab = workspace(withId: tabId) else { return nil } diff --git a/Sources/TabManager+GitMetadataPolling.swift b/Sources/TabManager+GitMetadataPolling.swift index 696840ca..9008fa60 100644 --- a/Sources/TabManager+GitMetadataPolling.swift +++ b/Sources/TabManager+GitMetadataPolling.swift @@ -254,9 +254,7 @@ extension TabManager { panelId: UUID, reason: String = "initial" ) { - guard !isStopped, - let workspace = workspace(withId: workspaceId), - !workspace.isRemoteWorkspace else { + guard !isStopped, workspace(withId: workspaceId) != nil else { return } scheduleWorkspaceGitMetadataRefreshIfPossible( diff --git a/Sources/TabManager+SessionPersistence.swift b/Sources/TabManager+SessionPersistence.swift index 8870734a..8664e376 100644 --- a/Sources/TabManager+SessionPersistence.swift +++ b/Sources/TabManager+SessionPersistence.swift @@ -13,19 +13,7 @@ extension TabManager { } func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { - // Only a *live* remote workspace is excluded -- a workspace the user disconnected from - // its remote host but kept using locally must not silently vanish from every snapshot - // for the rest of its life (see incident: three live local terminals lost on relaunch - // because `isRemoteWorkspace` alone stays true after disconnect). Whatever is skipped - // here is logged so the gap is never silent again. - for tab in tabs where tab.isLiveRemoteWorkspace { - dilog( - "session.snapshot", - "skipped workspace=\(tab.id.uuidString.prefix(8)) reason=remote panels=\(tab.panels.count)" - ) - } let restorableTabs = tabs - .filter { !$0.isLiveRemoteWorkspace } .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) let workspaceSnapshots = restorableTabs .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } @@ -44,7 +32,6 @@ extension TabManager { // panel/socket callbacks cannot keep mutating hidden pre-restore state. AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownAllPanels() - workspace.teardownRemoteConnection() workspace.owningTabManager = nil } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ea8cbc7d..7cf60e98 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1225,7 +1225,7 @@ class TabManager: ObservableObject { inheritedConfig = config } // Resolve placement against the pre-creation snapshot before Workspace init - // boots terminal state. The ssh/new-workspace path can otherwise crash while + // boots terminal state. The new-workspace path can otherwise crash while // reading @Published placement state from existing workspaces mid-creation. let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride) let ordinal = Self.nextPortOrdinal @@ -2030,7 +2030,6 @@ class TabManager: ObservableObject { func setTabColor(tabId: UUID, color: String?) { guard let tab = workspace(withId: tabId) else { return } tab.setCustomColor(color) - guard !tab.isRemoteWorkspace else { return } WorkspaceTabColorSettings.rememberColor( tab.customColor, forDirectory: tab.currentDirectory @@ -2170,7 +2169,7 @@ class TabManager: ObservableObject { /// Permanently tears down every workspace still owned by a closing window. Context removal, /// rather than tab mutation, excludes the window from later session snapshots; no live panel, - /// remote session, undo transfer, or callback may outlive the context. + /// undo transfer, or callback may outlive the context. func teardownForWindowClose(notifyOwner: Bool = true) { guard !isStopped else { return } isStopped = true @@ -2218,7 +2217,6 @@ class TabManager: ObservableObject { closedTerminalUndoStore.expireAll() for workspace in tabs { workspace.teardownAllPanels() - workspace.teardownRemoteConnection() unwireClosedBrowserTracking(for: workspace) workspace.owningTabManager = nil } @@ -2233,7 +2231,7 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { // Guard against tearing down a workspace this manager doesn't own (e.g. a // stray/external Workspace instance never inserted into `tabs`). Without - // this check, teardownAllPanels()/teardownRemoteConnection() below would + // this check, teardownAllPanels() below would // unconditionally mutate whatever workspace was passed in. guard tabs.contains(where: { $0.id == workspace.id }) else { return } guard tabs.count > 1 else { return } @@ -2246,7 +2244,6 @@ class TabManager: ObservableObject { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownAllPanels() - workspace.teardownRemoteConnection() unwireClosedBrowserTracking(for: workspace) workspace.owningTabManager = nil @@ -2730,27 +2727,14 @@ class TabManager: ObservableObject { func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { guard let tab = workspace(withId: tabId) else { return } guard tab.panels[surfaceId] != nil else { return } - let keepsRemoteWorkspaceOpen = - tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) #if DEBUG dlog( "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + - "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " + - "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(keepsRemoteWorkspaceOpen ? 1 : 0)" + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)" ) #endif - // Exiting the last SSH surface should demote the workspace back to a local one. - // Route through Workspace close handling so remote teardown and replacement-panel - // logic run before TabManager considers removing the workspace itself, including - // session-end paths where remote configuration was cleared before Ghostty delivered - // the child-exit callback. - if keepsRemoteWorkspaceOpen { - closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) - return - } - // Child-exit on the last panel should collapse the workspace, matching explicit close // semantics (and close the window when it was the last workspace). if tab.panels.count <= 1 { @@ -2830,58 +2814,6 @@ class TabManager: ObservableObject { focusedBrowserPanel?.showDeveloperToolsConsole() ?? false } - @discardableResult - func toggleReactGrabFromCurrentFocus() -> Bool { - guard let workspace = selectedWorkspace else { return false } - - let snapshots = workspace.panels.values.map { panel in - ReactGrabShortcutPanelSnapshot( - id: panel.id, - panelType: panel.panelType, - isFocused: panel.id == workspace.focusedPanelId - ) - } - guard let route = resolveReactGrabShortcutRoute(panels: snapshots), - let browserPanel = workspace.browserPanel(for: route.browserPanelId) else { - return false - } - - if let returnTerminalPanelId = route.returnTerminalPanelId { - browserPanel.armReactGrabRoundTrip(returnTo: returnTerminalPanelId) - } else { - browserPanel.clearReactGrabRoundTrip(reason: "shortcut.noReturnTarget") - } - - if workspace.focusedPanelId != browserPanel.id { - workspace.clearSplitZoom() - workspace.focusPanel(browserPanel.id) - } - - let didRequestExplicitWebViewFocus = browserPanel.requestExplicitWebViewFocus() -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusRequestResult " + - "workspace=\(workspace.id.uuidString.prefix(5)) " + - "browser=\(browserPanel.id.uuidString.prefix(5)) " + - "return=\(route.returnTerminalPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + - "success=\(didRequestExplicitWebViewFocus ? 1 : 0)" - ) -#endif - - Task { @MainActor [weak browserPanel] in - guard let browserPanel else { return } - if route.returnTerminalPanelId != nil { - await browserPanel.ensureReactGrabActive() - } else { - await browserPanel.toggleOrInjectReactGrab() - } - if !didRequestExplicitWebViewFocus { - _ = browserPanel.requestExplicitWebViewFocus() - } - } - return true - } - @discardableResult func toggleDesignModeFromCurrentFocus() -> Bool { guard let workspace = selectedWorkspace else { return false } diff --git a/Sources/TerminalController+AgentSupervision.swift b/Sources/TerminalController+AgentSupervision.swift index 34ab956c..dc7f7956 100644 --- a/Sources/TerminalController+AgentSupervision.swift +++ b/Sources/TerminalController+AgentSupervision.swift @@ -374,7 +374,7 @@ extension TerminalController { } let setupResult: V2CallResult = v2MainSync { - guard let (_, workspace) = agentWorkspace(id: workspaceId) else { + guard let (tabManager, workspace) = agentWorkspace(id: workspaceId) else { AgentSupervisionRegistry.shared.discard(id: agentId) return .err( code: "internal_error", @@ -397,6 +397,7 @@ extension TerminalController { if let initialCommand, let terminal = workspace.focusedTerminalPanel { terminal.sendInput(initialCommand + "\n") } + tabManager.openCompanionBrowserSplitIfEnabled(for: workspace) var result = payload result["agent_id"] = agentId.uuidString result["surface_id"] = v2OrNull(surfaceId?.uuidString) @@ -469,6 +470,7 @@ extension TerminalController { workspace.automaticAgentTitle = automaticTitle workspace.agentParentWorkspaceId = parentWorkspaceId let surfaceId = workspace.focusedTerminalPanel?.id + tabManager.openCompanionBrowserSplitIfEnabled(for: workspace) do { let record = try AgentSupervisionRegistry.shared.update( diff --git a/Sources/TerminalController+BrowserAutomation.swift b/Sources/TerminalController+BrowserAutomation.swift index efce8f56..25424365 100644 --- a/Sources/TerminalController+BrowserAutomation.swift +++ b/Sources/TerminalController+BrowserAutomation.swift @@ -4245,10 +4245,10 @@ extension TerminalController { return result } - /// Toggles Design Mode using the same focused-panel routing rule as the React Grab keyboard - /// shortcut (`resolveReactGrabShortcutRoute` / `activateDesignModeRoute`): no `surface_id` - /// required, targets the focused browser panel directly, or the workspace's single browser - /// panel when a terminal is focused. + /// Toggles Design Mode using the shared focused-panel routing rule + /// (`resolveDesignModeShortcutRoute` / `activateDesignModeRoute`): no `surface_id` required, targets the + /// focused browser panel directly, or the workspace's single browser panel when a terminal is + /// focused. func v2BrowserDesignModeToggle(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index 337641b5..aa15e3c5 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -76,23 +76,10 @@ extension TerminalController { requestedSurfaceId: requestedSurfaceId, validSurfaceIds: validSurfaceIds ) - guard let surfaceId, validSurfaceIds.contains(surfaceId) else { - // `isLiveRemoteWorkspace`, not `isRemoteWorkspace`: a disconnected-but-configured - // workspace has no active remote session to buffer this report for, and its - // panels are ordinary local shells again -- route it like any local workspace. - if tab.isLiveRemoteWorkspace, validSurfaceIds.isEmpty { - tab.rememberPendingRemoteSurfaceTTY(ttyName, requestedSurfaceId: requestedSurfaceId) - } - return - } + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { return } guard tab.setSidebarTTYName(panelId: surfaceId, ttyName: ttyName) else { return } - if tab.isLiveRemoteWorkspace { - tab.syncRemotePortScanTTYs() - _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) - } else { - PortScanner.shared.registerTTY(workspaceId: workspaceId, panelId: surfaceId, ttyName: ttyName) - } + PortScanner.shared.registerTTY(workspaceId: workspaceId, panelId: surfaceId, ttyName: ttyName) } return .ok([ @@ -112,18 +99,12 @@ extension TerminalController { if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { return v2InvalidParam("surface_id") } - let reason: WorkspaceRemoteSessionController.PortScanKickReason - if let rawReason = v2RawString(params, "reason") { - guard let parsedReason = Self.parseRemotePortScanKickReason(rawReason) else { - return .err( - code: "invalid_params", - message: "reason must be command or refresh", - data: nil - ) - } - reason = parsedReason - } else { - reason = .command + guard let reason = Self.normalizedPortScanKickReason(v2RawString(params, "reason") ?? "command") else { + return .err( + code: "invalid_params", + message: "reason must be command or refresh", + data: nil + ) } v2ScheduleTelemetryMutation(workspaceId: workspaceId) { [weak self] _, tab in @@ -136,24 +117,9 @@ extension TerminalController { requestedSurfaceId: requestedSurfaceId, validSurfaceIds: validSurfaceIds ) - guard let surfaceId, validSurfaceIds.contains(surfaceId) else { - // See v2SurfaceReportTTY above: `isLiveRemoteWorkspace`, not `isRemoteWorkspace` - // -- a disconnected-but-configured workspace has no active remote session to - // buffer this kick for. - if tab.isLiveRemoteWorkspace, validSurfaceIds.isEmpty { - tab.rememberPendingRemoteSurfacePortKick( - reason: reason, - requestedSurfaceId: requestedSurfaceId - ) - } - return - } + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { return } - if tab.isLiveRemoteWorkspace { - tab.kickRemotePortScan(panelId: surfaceId, reason: reason) - } else { - PortScanner.shared.kick(workspaceId: workspaceId, panelId: surfaceId) - } + PortScanner.shared.kick(workspaceId: workspaceId, panelId: surfaceId) } return .ok([ @@ -161,7 +127,7 @@ extension TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), "surface_id": v2OrNull(requestedSurfaceId?.uuidString), "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - "reason": reason.rawValue, + "reason": reason, ]) } @@ -586,22 +552,10 @@ extension TerminalController { } if let focusedSurfaceId = workspace.focusedPanelId, - validSurfaceIds.contains(focusedSurfaceId), - (!workspace.isRemoteWorkspace || workspace.isRemoteTerminalSurface(focusedSurfaceId)) { + validSurfaceIds.contains(focusedSurfaceId) { return focusedSurfaceId } - guard workspace.isRemoteWorkspace else { return nil } - - let remoteTerminalSurfaceIds = validSurfaceIds.filter { workspace.isRemoteTerminalSurface($0) } - if remoteTerminalSurfaceIds.count == 1 { - return remoteTerminalSurfaceIds.first - } - - if validSurfaceIds.count == 1 { - return validSurfaceIds.first - } - return nil } diff --git a/Sources/TerminalController+Workspace.swift b/Sources/TerminalController+Workspace.swift index 2e853c1d..115d00a7 100644 --- a/Sources/TerminalController+Workspace.swift +++ b/Sources/TerminalController+Workspace.swift @@ -1,4 +1,4 @@ -// Extracted from TerminalController.swift (nuclear-review #96): workspace.* command handlers (CRUD, remote session, action/tab.action verbs). +// Extracted from TerminalController.swift (nuclear-review #96): workspace.* command handlers (CRUD, action/tab.action verbs). import AppKit import Carbon.HIToolbox import Foundation @@ -33,7 +33,6 @@ extension TerminalController { "selected": selected, "pinned": workspace.isPinned, "listening_ports": workspace.listeningPorts, - "remote": workspace.remoteStatusPayload(), "current_directory": v2OrNull(workspace.currentDirectory), "custom_color": v2OrNull(workspace.customColor), "branch": v2OrNull(branch), @@ -486,285 +485,6 @@ extension TerminalController { return didEqualize || l || r } - nonisolated func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return v2InvalidParam("workspace_id") - } - return v2MainSync { - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - guard let destination = v2String(params, "destination") else { - return .err(code: "invalid_params", message: "Missing destination", data: nil) - } - - var sshPort: Int? - if v2HasNonNullParam(params, "port") { - guard let parsedPort = v2StrictInt(params, "port"), - parsedPort > 0, - parsedPort <= 65535 else { - return .err(code: "invalid_params", message: "port must be 1-65535", data: nil) - } - sshPort = parsedPort - } - - // Internal deterministic test hook: pin the local proxy listener port to force bind conflicts. - var localProxyPort: Int? - if v2HasNonNullParam(params, "local_proxy_port") { - guard let parsedLocalProxyPort = v2StrictInt(params, "local_proxy_port"), - parsedLocalProxyPort > 0, - parsedLocalProxyPort <= 65535 else { - return .err(code: "invalid_params", message: "local_proxy_port must be 1-65535", data: nil) - } - localProxyPort = parsedLocalProxyPort - } - - let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) - let sshOptions = v2StringArray(params, "ssh_options") ?? [] - let autoConnect = v2Bool(params, "auto_connect") ?? true - var relayPort: Int? - if v2HasNonNullParam(params, "relay_port") { - guard let parsedRelayPort = v2StrictInt(params, "relay_port"), - parsedRelayPort > 0, - parsedRelayPort <= 65535 else { - return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) - } - relayPort = parsedRelayPort - } - let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) - let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) - let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? - .trimmingCharacters(in: .whitespacesAndNewlines) - let localSocketPath = v2RawString(params, "local_socket_path") - let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? - .trimmingCharacters(in: .whitespacesAndNewlines) - if relayPort != nil { - guard let relayID, !relayID.isEmpty else { - return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) - } - guard let relayToken, - relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { - return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) - } - } - -#if DEBUG - dlog( - "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + - "target=\(destination) port=\(sshPort.map(String.init) ?? "nil") " + - "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + - "localSocket=\(localSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? localSocketPath! : "nil") " + - "sshOptions=\(sshOptions.joined(separator: "|"))" - ) -#endif - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - } - - let config = WorkspaceRemoteConfiguration( - destination: destination, - port: sshPort, - identityFile: identityFile?.isEmpty == true ? nil : identityFile, - sshOptions: sshOptions, - localProxyPort: localProxyPort, - relayPort: relayPort, - relayID: relayID?.isEmpty == true ? nil : relayID, - relayToken: relayToken?.isEmpty == true ? nil : relayToken, - localSocketPath: localSocketPath, - terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand, - foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken - ) - workspace.configureRemoteConnection(config, autoConnect: autoConnect) - - let windowId = v2ResolveWindowId(tabManager: owner) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "remote": workspace.remoteStatusPayload(), - ]) - } - } - - nonisolated func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return v2InvalidParam("workspace_id") - } - let clearConfiguration = v2Bool(params, "clear") ?? false - return v2MainSync { - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - } - - workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) - let windowId = v2ResolveWindowId(tabManager: owner) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "remote": workspace.remoteStatusPayload(), - ]) - } - } - - nonisolated func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return v2InvalidParam("workspace_id") - } - return v2MainSync { - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - } - - guard workspace.remoteConfiguration != nil else { - return .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - } - - workspace.reconnectRemoteConnection() - let windowId = v2ResolveWindowId(tabManager: owner) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "remote": workspace.remoteStatusPayload(), - ]) - } - } - - nonisolated func v2WorkspaceRemoteForegroundAuthReady(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return v2InvalidParam("workspace_id") - } - let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? - .trimmingCharacters(in: .whitespacesAndNewlines) - return v2MainSync { - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - } - - workspace.notifyRemoteForegroundAuthenticationReady(token: foregroundAuthToken) - let windowId = v2ResolveWindowId(tabManager: owner) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "remote": workspace.remoteStatusPayload(), - ]) - } - } - - nonisolated func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return v2InvalidParam("workspace_id") - } - return v2MainSync { - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - } - let windowId = v2ResolveWindowId(tabManager: owner) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "remote": workspace.remoteStatusPayload(), - ]) - } - } - - nonisolated func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - guard let relayPort = v2StrictInt(params, "relay_port"), - relayPort > 0, - relayPort <= 65535 else { - return v2InvalidParam("relay_port") - } - - return v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, - ]) - } - workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) - let windowId = v2ResolveWindowId(tabManager: owner) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": workspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, - "remote": workspace.remoteStatusPayload(), - ]) - } - } - // `surface.report_tty` and `surface.ports_kick` are high-frequency telemetry commands (see // CLAUDE.md "Socket command threading policy"): they must not block the calling socket // thread with `DispatchQueue.main.sync`. Both handlers now follow the same off-main-parse + diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1d82ec01..eabe5a30 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -10,7 +10,6 @@ extension Notification.Name { static let terminalSurfaceHostedViewDidMoveToWindow = Notification.Name("programa.terminalSurfaceHostedViewDidMoveToWindow") static let mainWindowContextsDidChange = Notification.Name("programa.mainWindowContextsDidChange") static let browserDownloadEventDidArrive = Notification.Name("programa.browserDownloadEventDidArrive") - static let reactGrabDidCopySelection = Notification.Name("programa.reactGrabDidCopySelection") static let designModeDidCapture = Notification.Name("programa.designModeDidCapture") } @@ -121,7 +120,6 @@ class TerminalController { enum SocketConnectionSource: Sendable { case unix(UnixClientPolicy) case rejectedUnix - case mobileBridge } enum AcceptFailureRecoveryAction: Equatable { @@ -504,16 +502,6 @@ class TerminalController { } } - private nonisolated func mobileBridgeRequestPolicy() -> SocketRequestPolicy { - withListenerState { - SocketRequestPolicy( - socketPath: socketPath, - accessMode: accessMode, - requiresPasswordAuthentication: false - ) - } - } - private nonisolated func socketControlPasswordDidChange() { withListenerState { authCredentialEpoch &+= 1 @@ -865,14 +853,16 @@ class TerminalController { } } - nonisolated static func parseRemotePortScanKickReason( - _ rawReason: String - ) -> WorkspaceRemoteSessionController.PortScanKickReason? { + /// Validates and canonicalizes the optional `reason` argument to `surface.ports_kick` + /// so a caller that sends a typo gets `invalid_params` instead of a silent no-op; the + /// response echoes back the canonical value. The value is part of the socket contract + /// only for that validation and echo — it does not select a scan strategy. + nonisolated static func normalizedPortScanKickReason(_ rawReason: String) -> String? { switch rawReason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "command", "running", "foreground", "start": - return .command + return "command" case "refresh", "prompt", "idle": - return .refresh + return "refresh" default: return nil } @@ -1770,9 +1760,6 @@ class TerminalController { case .rejectedUnix: closeReason = "listener_stopped" return - case .mobileBridge: - requestPolicy = mobileBridgeRequestPolicy() - unixPolicy = nil } defer { if unixPolicy != nil { @@ -2011,18 +1998,6 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceLast(params: params)) case "workspace.equalize_splits": return v2Result(id: id, self.v2WorkspaceEqualizeSplits(params: params)) - case "workspace.remote.configure": - return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) - case "workspace.remote.foreground_auth_ready": - return v2Result(id: id, self.v2WorkspaceRemoteForegroundAuthReady(params: params)) - case "workspace.remote.reconnect": - return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) - case "workspace.remote.disconnect": - return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) - case "workspace.remote.status": - return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) - case "workspace.remote.terminal_session_end": - return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) case "workspace.set_status": return v2Result(id: id, self.v2WorkspaceSetStatus(params: params)) case "workspace.clear_status": diff --git a/Sources/TerminalDirectoryOpener.swift b/Sources/TerminalDirectoryOpener.swift index 1b2d3073..1e7f946d 100644 --- a/Sources/TerminalDirectoryOpener.swift +++ b/Sources/TerminalDirectoryOpener.swift @@ -75,7 +75,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { case terminal case tower case vscode - case vscodeInline case warp case windsurf case xcode @@ -84,13 +83,11 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { struct DetectionEnvironment { let homeDirectoryPath: String let fileExistsAtPath: (String) -> Bool - let isExecutableFileAtPath: (String) -> Bool let applicationPathForBundleIdentifier: (String) -> String? static let live = DetectionEnvironment( homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, - isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, applicationPathForBundleIdentifier: { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0)?.path } @@ -131,8 +128,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") case .vscode: return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") - case .vscodeInline: - return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") case .warp: return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") case .windsurf: @@ -167,8 +162,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return common + ["tower", "git", "client"] case .vscode: return common + ["vs", "code", "visual", "studio", "desktop", "app"] - case .vscodeInline: - return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] case .warp: return common + ["warp", "terminal", "shell"] case .windsurf: @@ -181,12 +174,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { } func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { - guard let applicationPath = applicationPath(in: environment) else { return false } - guard self == .vscodeInline else { return true } - return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), - isExecutableAtPath: environment.isExecutableFileAtPath - ) != nil + applicationPath(in: environment) != nil } func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { @@ -239,7 +227,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { case .iterm2: return ["com.googlecode.iterm2"] case .terminal: return ["com.apple.Terminal"] case .tower: return ["com.fournova.Tower3", "com.fournova.Tower"] - case .vscode, .vscodeInline: return ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders"] + case .vscode: return ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders"] case .warp: return ["dev.warp.Warp-Stable"] case .windsurf: return ["com.exafunction.windsurf"] case .xcode: return ["com.apple.dt.Xcode"] @@ -279,11 +267,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { "/Applications/Visual Studio Code.app", "/Applications/Code.app", ] - case .vscodeInline: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] case .warp: return ["/Applications/Warp.app"] case .windsurf: diff --git a/Sources/TerminalImageTransfer.swift b/Sources/TerminalImageTransfer.swift deleted file mode 100644 index bcef09e1..00000000 --- a/Sources/TerminalImageTransfer.swift +++ /dev/null @@ -1,346 +0,0 @@ -import Foundation -import AppKit - -enum TerminalImageTransferMode { - case paste - case drop -} - -enum TerminalRemoteUploadTarget: Equatable { - case workspaceRemote - case detectedSSH(DetectedSSHSession) -} - -enum TerminalImageTransferTarget: Equatable { - case local - case remote(TerminalRemoteUploadTarget) -} - -enum TerminalImageTransferPlan: Equatable { - case insertText(String) - case uploadFiles([URL], TerminalRemoteUploadTarget) - case reject -} - -enum TerminalImageTransferPreparedContent: Equatable { - case insertText(String) - case fileURLs([URL]) - case reject -} - -enum TerminalImageTransferExecutionError: Error { - case cancelled -} - -final class TerminalImageTransferOperation: @unchecked Sendable { - private enum State { - case running - case cancelled - case finished - } - - private let lock = NSLock() - private var state: State = .running - private var cancellationHandler: (() -> Void)? - - var isCancelled: Bool { - lock.lock() - defer { lock.unlock() } - return state == .cancelled - } - - func installCancellationHandler(_ handler: @escaping () -> Void) { - var invokeImmediately = false - lock.lock() - switch state { - case .running: - cancellationHandler = handler - case .cancelled: - invokeImmediately = true - case .finished: - break - } - lock.unlock() - - if invokeImmediately { - handler() - } - } - - func clearCancellationHandler() { - lock.lock() - if state == .running { - cancellationHandler = nil - } - lock.unlock() - } - - @discardableResult - func cancel() -> Bool { - let handler: (() -> Void)? - lock.lock() - guard state == .running else { - lock.unlock() - return false - } - state = .cancelled - handler = cancellationHandler - cancellationHandler = nil - lock.unlock() - - handler?() - return true - } - - @discardableResult - func finish() -> Bool { - lock.lock() - defer { lock.unlock() } - guard state == .running else { return false } - state = .finished - cancellationHandler = nil - return true - } - - func throwIfCancelled() throws { - if isCancelled { - throw TerminalImageTransferExecutionError.cancelled - } - } -} - -enum TerminalImageTransferPlanner { - static func plan( - pasteboard: NSPasteboard, - mode: TerminalImageTransferMode, - target: TerminalImageTransferTarget - ) -> TerminalImageTransferPlan { - plan( - preparedContent: prepare(pasteboard: pasteboard, mode: mode), - target: target - ) - } - - static func plan( - pasteboard: NSPasteboard, - mode: TerminalImageTransferMode, - resolveTarget: () -> TerminalImageTransferTarget - ) -> TerminalImageTransferPlan { - let preparedContent = prepare(pasteboard: pasteboard, mode: mode) - switch preparedContent { - case .insertText, .reject: - return plan(preparedContent: preparedContent, target: .local) - case .fileURLs: - return plan(preparedContent: preparedContent, target: resolveTarget()) - } - } - - static func prepare( - pasteboard: NSPasteboard, - mode: TerminalImageTransferMode - ) -> TerminalImageTransferPreparedContent { - switch mode { - case .paste: - return preparePaste(pasteboard: pasteboard) - case .drop: - return prepareDrop(pasteboard: pasteboard) - } - } - - static func plan( - preparedContent: TerminalImageTransferPreparedContent, - target: TerminalImageTransferTarget - ) -> TerminalImageTransferPlan { - switch preparedContent { - case .insertText(let text): - return .insertText(text) - case .fileURLs(let fileURLs): - return plan(fileURLs: fileURLs, target: target) - case .reject: - return .reject - } - } - - static func plan(fileURLs: [URL], target: TerminalImageTransferTarget) -> TerminalImageTransferPlan { - guard !fileURLs.isEmpty else { return .reject } - - switch target { - case .local: - return .insertText(insertedText(for: fileURLs)) - case .remote(let remoteTarget): - guard fileURLs.allSatisfy(isRemoteUploadableFileURL) else { - return .insertText(insertedText(for: fileURLs)) - } - return .uploadFiles(fileURLs, remoteTarget) - } - } - - @discardableResult - static func executeForTesting( - plan: TerminalImageTransferPlan, - operation: TerminalImageTransferOperation? = nil, - uploadWorkspaceRemote: ([URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void, - uploadDetectedSSH: (DetectedSSHSession, [URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void, - insertText: @escaping (String) -> Void, - onFailure: @escaping (Error) -> Void - ) -> TerminalImageTransferOperation? { - execute( - plan: plan, - operation: operation, - uploadWorkspaceRemote: uploadWorkspaceRemote, - uploadDetectedSSH: uploadDetectedSSH, - insertText: insertText, - onFailure: onFailure - ) - } - - @discardableResult - static func execute( - plan: TerminalImageTransferPlan, - operation: TerminalImageTransferOperation? = nil, - uploadWorkspaceRemote: ([URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void, - uploadDetectedSSH: (DetectedSSHSession, [URL], TerminalImageTransferOperation, @escaping (Result<[String], Error>) -> Void) -> Void, - insertText: @escaping (String) -> Void, - onFailure: @escaping (Error) -> Void - ) -> TerminalImageTransferOperation? { - switch plan { - case .insertText(let text): - if let operation, !operation.finish() { - return operation - } - insertText(text) - return operation - case .uploadFiles(let fileURLs, .workspaceRemote): - let operation = operation ?? TerminalImageTransferOperation() - uploadWorkspaceRemote(fileURLs, operation) { result in - guard operation.finish() else { return } - finishUpload(result: result, insertText: insertText, onFailure: onFailure) - } - return operation - case .uploadFiles(let fileURLs, .detectedSSH(let session)): - let operation = operation ?? TerminalImageTransferOperation() - uploadDetectedSSH(session, fileURLs, operation) { result in - guard operation.finish() else { return } - finishUpload(result: result, insertText: insertText, onFailure: onFailure) - } - return operation - case .reject: - return operation - } - } - - static func escapeForShell(_ value: String) -> String { - GhosttyPasteboardHelper.escapeForShell(value) - } - - private static func insertedText(for fileURLs: [URL]) -> String { - fileURLs - .map { escapeForShell($0.path) } - .joined(separator: " ") - } - - private static func isRemoteUploadableFileURL(_ fileURL: URL) -> Bool { - let normalizedFileURL = fileURL.standardizedFileURL - guard normalizedFileURL.isFileURL, - let resourceValues = try? normalizedFileURL.resourceValues(forKeys: [.isRegularFileKey]), - resourceValues.isRegularFile == true else { - return false - } - return true - } - - private static func preparePaste( - pasteboard: NSPasteboard - ) -> TerminalImageTransferPreparedContent { - let fileURLs = fileURLs(from: pasteboard) - if !fileURLs.isEmpty { - return .fileURLs(fileURLs) - } - - if let string = GhosttyPasteboardHelper.stringContents(from: pasteboard), !string.isEmpty { - return .insertText(string) - } - - if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { - return .fileURLs([imageURL]) - } - - if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { - return .insertText(escapeForShell(rawURL)) - } - - return .reject - } - - private static func prepareDrop( - pasteboard: NSPasteboard - ) -> TerminalImageTransferPreparedContent { - let fileURLs = materializedFileURLs(from: pasteboard) - if !fileURLs.isEmpty { - return .fileURLs(fileURLs) - } - - if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { - return .insertText(escapeForShell(rawURL)) - } - - if let string = pasteboard.string(forType: .string), !string.isEmpty { - return .insertText(string) - } - - return .reject - } - - private static func materializedFileURLs(from pasteboard: NSPasteboard) -> [URL] { - let urls = fileURLs(from: pasteboard) - if !urls.isEmpty { - return urls - } - if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { - return [imageURL] - } - return [] - } - - private static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { - guard let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { - return [] - } - return urls.filter(\.isFileURL) - } - - private static func finishUpload( - result: Result<[String], Error>, - insertText: @escaping (String) -> Void, - onFailure: @escaping (Error) -> Void - ) { - switch result { - case .success(let remotePaths): - let content = remotePaths - .map(escapeForShell) - .joined(separator: " ") - guard !content.isEmpty else { - onFailure(NSError(domain: "programa.remote.drop", code: 5)) - return - } - insertText(content) - case .failure(let error): - onFailure(error) - } - } -} - -extension TerminalSurface { - @MainActor - func resolvedImageTransferTarget() -> TerminalImageTransferTarget { - guard let workspace = owningWorkspace() else { return .local } - if workspace.isRemoteTerminalSurface(id) { - return .remote(.workspaceRemote) - } - if let ttyName = workspace.surfaceTTYNames[id], - let session = TerminalSSHSessionDetector.detect(forTTY: ttyName) { - return .remote(.detectedSSH(session)) - } - return .local - } -} diff --git a/Sources/TerminalPasteboardPlanner.swift b/Sources/TerminalPasteboardPlanner.swift new file mode 100644 index 00000000..3ea1200d --- /dev/null +++ b/Sources/TerminalPasteboardPlanner.swift @@ -0,0 +1,101 @@ +import Foundation +import AppKit + +enum TerminalPasteboardInsertionMode { + case paste + case drop +} + +enum TerminalPasteboardInsertion: Equatable { + case insertText(String) + case reject +} + +enum TerminalPasteboardPlanner { + static func plan( + pasteboard: NSPasteboard, + mode: TerminalPasteboardInsertionMode + ) -> TerminalPasteboardInsertion { + switch mode { + case .paste: + return planPaste(pasteboard: pasteboard) + case .drop: + return planDrop(pasteboard: pasteboard) + } + } + + static func plan(fileURLs: [URL]) -> TerminalPasteboardInsertion { + guard !fileURLs.isEmpty else { return .reject } + return .insertText(insertedText(for: fileURLs)) + } + + static func escapeForShell(_ value: String) -> String { + GhosttyPasteboardHelper.escapeForShell(value) + } + + private static func insertedText(for fileURLs: [URL]) -> String { + fileURLs + .map { escapeForShell($0.path) } + .joined(separator: " ") + } + + private static func planPaste( + pasteboard: NSPasteboard + ) -> TerminalPasteboardInsertion { + let fileURLs = fileURLs(from: pasteboard) + if !fileURLs.isEmpty { + return .insertText(insertedText(for: fileURLs)) + } + + if let string = GhosttyPasteboardHelper.stringContents(from: pasteboard), !string.isEmpty { + return .insertText(string) + } + + if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { + return .insertText(insertedText(for: [imageURL])) + } + + if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { + return .insertText(escapeForShell(rawURL)) + } + + return .reject + } + + private static func planDrop( + pasteboard: NSPasteboard + ) -> TerminalPasteboardInsertion { + let fileURLs = materializedFileURLs(from: pasteboard) + if !fileURLs.isEmpty { + return .insertText(insertedText(for: fileURLs)) + } + + if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { + return .insertText(escapeForShell(rawURL)) + } + + if let string = pasteboard.string(forType: .string), !string.isEmpty { + return .insertText(string) + } + + return .reject + } + + private static func materializedFileURLs(from pasteboard: NSPasteboard) -> [URL] { + let urls = fileURLs(from: pasteboard) + if !urls.isEmpty { + return urls + } + if let imageURL = GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: true) { + return [imageURL] + } + return [] + } + + private static func fileURLs(from pasteboard: NSPasteboard) -> [URL] { + guard let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { + return [] + } + return urls.filter(\.isFileURL) + } +} diff --git a/Sources/TerminalSSHSessionDetector.swift b/Sources/TerminalSSHSessionDetector.swift deleted file mode 100644 index 6af35792..00000000 --- a/Sources/TerminalSSHSessionDetector.swift +++ /dev/null @@ -1,724 +0,0 @@ -import Foundation -import Darwin - -struct DetectedSSHSession: Equatable { - let destination: String - let port: Int? - let identityFile: String? - let configFile: String? - let jumpHost: String? - let controlPath: String? - let useIPv4: Bool - let useIPv6: Bool - let forwardAgent: Bool - let compressionEnabled: Bool - let sshOptions: [String] - - func uploadDroppedFiles( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation, - completion: @escaping (Result<[String], Error>) -> Void - ) { - let session = self - DispatchQueue.global(qos: .userInitiated).async { - let result: Result<[String], Error> - do { - let remotePaths = try session.uploadDroppedFilesSync(fileURLs, operation: operation) - do { - try operation.throwIfCancelled() - result = .success(remotePaths) - } catch { - session.cleanupUploadedRemotePathsAsync(remotePaths) - result = .failure(error) - } - } catch { - result = .failure(error) - } - DispatchQueue.main.async { - if operation.isCancelled { - if case .success(let remotePaths) = result { - session.cleanupUploadedRemotePathsAsync(remotePaths) - } - completion(.failure(TerminalImageTransferExecutionError.cancelled)) - } else { - completion(result) - } - } - } - } - - func uploadDroppedFiles( - _ fileURLs: [URL], - completion: @escaping (Result<[String], Error>) -> Void - ) { - uploadDroppedFiles( - fileURLs, - operation: TerminalImageTransferOperation(), - completion: completion - ) - } - -#if DEBUG - typealias ProcessOverrideResultForTesting = ( - status: Int32, - stdout: String, - stderr: String - ) - - static var runProcessOverrideForTesting: (( - String, - [String], - TimeInterval, - TerminalImageTransferOperation? - ) throws -> ProcessOverrideResultForTesting)? - - func uploadDroppedFilesSyncForTesting( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation = TerminalImageTransferOperation() - ) throws -> [String] { - try uploadDroppedFilesSync(fileURLs, operation: operation) - } -#endif - - private func uploadDroppedFilesSync( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation - ) throws -> [String] { - try performSCPUploadWithCancelCleanup( - items: fileURLs, - checkCancelled: { try operation.throwIfCancelled() }, - performUpload: { localURL, record in - let normalizedLocalURL = localURL.standardizedFileURL - guard normalizedLocalURL.isFileURL else { - throw NSError(domain: "programa.detected-ssh.drop", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "dropped item is not a file URL", - ]) - } - - let remotePath = WorkspaceRemoteSessionController.remoteDropPath(for: normalizedLocalURL) - let result = try Self.runProcess( - executable: "/usr/bin/scp", - arguments: scpArguments(localPath: normalizedLocalURL.path, remotePath: remotePath), - timeout: 45, - operation: operation - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? - "scp exited \(result.status)" - throw NSError(domain: "programa.detected-ssh.drop", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "failed to upload dropped file: \(detail)", - ]) - } - - record(remotePath) - }, - cleanup: { cleanupUploadedRemotePaths($0) } - ) - } - - private func scpArguments(localPath: String, remotePath: String) -> [String] { - var args: [String] = ["-q"] - + RemoteSSHConnectionPolicy.keepaliveArguments - + RemoteSSHConnectionPolicy.batchModeArguments - - if useIPv4 { - args.append("-4") - } else if useIPv6 { - args.append("-6") - } - if forwardAgent { - args.append("-A") - } - if compressionEnabled { - args.append("-C") - } - if let configFile, !configFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-F", configFile] - } - if let jumpHost, !jumpHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-J", jumpHost] - } - if let port { - args += ["-P", String(port)] - } - if let identityFile, !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - if let controlPath, - !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !RemoteSSHConnectionPolicy.hasOptionKey(sshOptions, key: "ControlPath") { - args += ["-o", "ControlPath=\(controlPath)"] - } - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: sshOptions) - for option in sshOptions { - args += ["-o", option] - } - - args += [localPath, "\(RemoteSSHConnectionPolicy.scpRemoteDestination(destination)):\(remotePath)"] - return args - } - - private func sshArguments(command: String) -> [String] { - var args: [String] = ["-T"] - + RemoteSSHConnectionPolicy.keepaliveArguments - + RemoteSSHConnectionPolicy.batchModeArguments - - if useIPv4 { - args.append("-4") - } else if useIPv6 { - args.append("-6") - } - if forwardAgent { - args.append("-A") - } - if compressionEnabled { - args.append("-C") - } - if let configFile, !configFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-F", configFile] - } - if let jumpHost, !jumpHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-J", jumpHost] - } - if let port { - args += ["-p", String(port)] - } - if let identityFile, !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - if let controlPath, - !controlPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !RemoteSSHConnectionPolicy.hasOptionKey(sshOptions, key: "ControlPath") { - args += ["-o", "ControlPath=\(controlPath)"] - } - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: sshOptions) - for option in sshOptions { - args += ["-o", option] - } - - args += [destination, command] - return args - } - - private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { - guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" - _ = try? Self.runProcess( - executable: "/usr/bin/ssh", - arguments: sshArguments(command: cleanupCommand), - timeout: 8 - ) - } - - private func cleanupUploadedRemotePathsAsync(_ remotePaths: [String]) { - guard !remotePaths.isEmpty else { return } - let session = self - DispatchQueue.global(qos: .utility).async { - session.cleanupUploadedRemotePaths(remotePaths) - } - } - - private struct CommandResult { - let status: Int32 - let stdout: String - let stderr: String - } - - private static func runProcess( - executable: String, - arguments: [String], - timeout: TimeInterval, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { -#if DEBUG - if let runProcessOverrideForTesting { - let result = try runProcessOverrideForTesting(executable, arguments, timeout, operation) - return CommandResult(status: result.status, stdout: result.stdout, stderr: result.stderr) - } -#endif - - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardInput = FileHandle.nullDevice - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - try operation?.throwIfCancelled() - try process.run() - operation?.installCancellationHandler { - if process.isRunning { - process.terminate() - } - } - defer { operation?.clearCancellationHandler() } - - let exitSignal = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - exitSignal.signal() - } - - func terminateProcessAndWait() { - process.terminate() - _ = exitSignal.wait(timeout: .now() + 1) - if process.isRunning { - _ = Darwin.kill(process.processIdentifier, SIGKILL) - process.waitUntilExit() - } - } - - if exitSignal.wait(timeout: .now() + timeout) == .timedOut { - if operation?.isCancelled == true { - terminateProcessAndWait() - throw TerminalImageTransferExecutionError.cancelled - } - terminateProcessAndWait() - throw NSError(domain: "programa.detected-ssh.drop", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "scp timed out", - ]) - } - - let stdout = String( - data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - let stderr = String( - data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - ) ?? "" - if operation?.isCancelled == true { - throw TerminalImageTransferExecutionError.cancelled - } - return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) - } - - private static func bestErrorLine(stderr: String, stdout: String) -> String? { - let stderrLine = stderr - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .first(where: { !$0.isEmpty }) - if let stderrLine { - return stderrLine - } - - return stdout - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .first(where: { !$0.isEmpty }) - } - -#if DEBUG - func scpArgumentsForTesting(localPath: String, remotePath: String) -> [String] { - scpArguments(localPath: localPath, remotePath: remotePath) - } -#endif -} - -enum TerminalSSHSessionDetector { - struct ProcessSnapshot: Equatable { - let pid: Int32 - let pgid: Int32 - let tpgid: Int32 - let tty: String - let executableName: String - } - - static func detect(forTTY ttyName: String) -> DetectedSSHSession? { - let normalizedTTY = normalizeTTYName(ttyName) - guard !normalizedTTY.isEmpty else { return nil } - let processes = processSnapshots(forTTY: normalizedTTY) - guard !processes.isEmpty else { return nil } - - var argumentsByPID: [Int32: [String]] = [:] - for process in processes where isForegroundSSHProcess(process, ttyName: normalizedTTY) { - if let args = commandLineArguments(forPID: process.pid) { - argumentsByPID[process.pid] = args - } - } - - return detectForTesting( - ttyName: normalizedTTY, - processes: processes, - argumentsByPID: argumentsByPID - ) - } - - static func detectForTesting( - ttyName: String, - processes: [ProcessSnapshot], - argumentsByPID: [Int32: [String]] - ) -> DetectedSSHSession? { - let normalizedTTY = normalizeTTYName(ttyName) - guard !normalizedTTY.isEmpty else { return nil } - - let candidates = processes - .filter { isForegroundSSHProcess($0, ttyName: normalizedTTY) } - .sorted { lhs, rhs in - if lhs.pid != rhs.pid { return lhs.pid > rhs.pid } - return lhs.pgid > rhs.pgid - } - - for candidate in candidates { - guard let arguments = argumentsByPID[candidate.pid], - let session = parseSSHCommandLine(arguments) else { - continue - } - return session - } - - return nil - } - - private static let psPath = "/bin/ps" - private static let noArgumentFlags = Set("46AaCfGgKkMNnqsTtVvXxYy") - private static let valueArgumentFlags = Set("BbcDEeFIiJLlmOopQRSWw") - private static let filteredSSHOptionKeys: Set<String> = [ - "batchmode", - "controlmaster", - "controlpersist", - "forkafterauthentication", - "localcommand", - "permitlocalcommand", - "remotecommand", - "requesttty", - "sendenv", - "sessiontype", - "setenv", - "stdioforward", - ] - - private static func normalizeTTYName(_ ttyName: String) -> String { - let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - if let lastComponent = trimmed.split(separator: "/").last { - return String(lastComponent) - } - return trimmed - } - - private static func isForegroundSSHProcess(_ process: ProcessSnapshot, ttyName: String) -> Bool { - normalizeTTYName(process.tty) == normalizeTTYName(ttyName) && - process.executableName == "ssh" && - process.pgid > 0 && - process.tpgid > 0 && - process.pgid == process.tpgid - } - - private static func processSnapshots(forTTY ttyName: String) -> [ProcessSnapshot] { - let process = Process() - let pipe = Pipe() - process.executableURL = URL(fileURLWithPath: psPath) - process.arguments = ["-ww", "-t", ttyName, "-o", "pid=,pgid=,tpgid=,tty=,ucomm="] - process.standardInput = FileHandle.nullDevice - process.standardOutput = pipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - return [] - } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard process.terminationStatus == 0, - let output = String(data: data, encoding: .utf8) else { - return [] - } - - return output - .split(separator: "\n") - .compactMap(parseProcessSnapshot) - } - - private static func parseProcessSnapshot(_ line: Substring) -> ProcessSnapshot? { - let parts = line.split(maxSplits: 4, whereSeparator: \.isWhitespace) - guard parts.count == 5, - let pid = Int32(parts[0]), - let pgid = Int32(parts[1]), - let tpgid = Int32(parts[2]) else { - return nil - } - - return ProcessSnapshot( - pid: pid, - pgid: pgid, - tpgid: tpgid, - tty: String(parts[3]), - executableName: String(parts[4]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - ) - } - - private static func commandLineArguments(forPID pid: Int32) -> [String]? { - var mib = [CTL_KERN, KERN_PROCARGS2, pid] - var size: size_t = 0 - guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 4 else { - return nil - } - - var buffer = [UInt8](repeating: 0, count: size) - let success = buffer.withUnsafeMutableBytes { rawBuffer in - sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0 - } - guard success else { return nil } - - return parseKernProcArgs(Array(buffer.prefix(Int(size)))) - } - - private static func parseKernProcArgs(_ bytes: [UInt8]) -> [String]? { - guard bytes.count > 4 else { return nil } - - var argcRaw: Int32 = 0 - withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in - rawBuffer.copyBytes(from: bytes.prefix(4)) - } - let argc = Int(Int32(littleEndian: argcRaw)) - guard argc > 0 else { return nil } - - var index = 4 - while index < bytes.count, bytes[index] != 0 { - index += 1 - } - while index < bytes.count, bytes[index] == 0 { - index += 1 - } - - var arguments: [String] = [] - while index < bytes.count, arguments.count < argc { - let start = index - while index < bytes.count, bytes[index] != 0 { - index += 1 - } - guard let argument = String(bytes: bytes[start..<index], encoding: .utf8) else { - return nil - } - arguments.append(argument) - while index < bytes.count, bytes[index] == 0 { - index += 1 - } - } - - return arguments.count == argc ? arguments : nil - } - - private static func parseSSHCommandLine(_ arguments: [String]) -> DetectedSSHSession? { - guard !arguments.isEmpty else { return nil } - - var index = 0 - if let executable = arguments.first?.split(separator: "/").last, - executable == "ssh" { - index = 1 - } - - var destination: String? - var port: Int? - var identityFile: String? - var configFile: String? - var jumpHost: String? - var controlPath: String? - var loginName: String? - var useIPv4 = false - var useIPv6 = false - var forwardAgent = false - var compressionEnabled = false - var sshOptions: [String] = [] - - func consumeValue(_ value: String, for option: Character) -> Bool { - let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedValue.isEmpty else { return false } - - switch option { - case "p": - guard let parsedPort = Int(trimmedValue) else { return false } - port = parsedPort - return true - case "i": - identityFile = trimmedValue - return true - case "F": - configFile = trimmedValue - return true - case "J": - jumpHost = trimmedValue - return true - case "S": - controlPath = trimmedValue - return true - case "l": - loginName = trimmedValue - return true - case "o": - return consumeSSHOption( - trimmedValue, - port: &port, - identityFile: &identityFile, - controlPath: &controlPath, - jumpHost: &jumpHost, - loginName: &loginName, - sshOptions: &sshOptions - ) - default: - return valueArgumentFlags.contains(option) - } - } - - while index < arguments.count { - let argument = arguments[index] - if argument == "--" { - index += 1 - if index < arguments.count { - destination = arguments[index] - } - break - } - if !argument.hasPrefix("-") || argument == "-" { - destination = argument - break - } - - if argument.count > 2, - let option = argument.dropFirst().first, - valueArgumentFlags.contains(option) { - guard consumeValue(String(argument.dropFirst(2)), for: option) else { return nil } - index += 1 - continue - } - - if argument.count == 2, - let optionCharacter = argument.dropFirst().first, - valueArgumentFlags.contains(optionCharacter) { - let nextIndex = index + 1 - guard nextIndex < arguments.count, - consumeValue(arguments[nextIndex], for: optionCharacter) else { - return nil - } - index += 2 - continue - } - - let flags = Array(argument.dropFirst()) - guard !flags.isEmpty, flags.allSatisfy({ noArgumentFlags.contains($0) }) else { - return nil - } - for flag in flags { - switch flag { - case "4": - useIPv4 = true - useIPv6 = false - case "6": - useIPv6 = true - useIPv4 = false - case "A": - forwardAgent = true - case "C": - compressionEnabled = true - default: - break - } - } - index += 1 - } - - guard let destination else { return nil } - let finalDestination = resolveDestination(destination, loginName: loginName) - guard !finalDestination.isEmpty else { return nil } - - return DetectedSSHSession( - destination: finalDestination, - port: port, - identityFile: identityFile, - configFile: configFile, - jumpHost: jumpHost, - controlPath: controlPath, - useIPv4: useIPv4, - useIPv6: useIPv6, - forwardAgent: forwardAgent, - compressionEnabled: compressionEnabled, - sshOptions: sshOptions - ) - } - - private static func consumeSSHOption( - _ option: String, - port: inout Int?, - identityFile: inout String?, - controlPath: inout String?, - jumpHost: inout String?, - loginName: inout String?, - sshOptions: inout [String] - ) -> Bool { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - let key = RemoteSSHConnectionPolicy.optionKey(trimmed) - let value = sshOptionValue(trimmed) - - switch key { - case "port": - if let value, let parsedPort = Int(value) { - port = parsedPort - return true - } - return false - case "identityfile": - if let value, !value.isEmpty { - identityFile = value - return true - } - return false - case "controlpath": - if let value, !value.isEmpty { - controlPath = value - return true - } - return false - case "proxyjump": - if let value, !value.isEmpty { - jumpHost = value - return true - } - return false - case "user": - if let value, !value.isEmpty { - loginName = value - return true - } - return false - case let key? where filteredSSHOptionKeys.contains(key): - return true - case .some, .none: - sshOptions.append(trimmed) - return true - } - } - - private static func resolveDestination(_ destination: String, loginName: String?) -> String { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDestination.isEmpty else { return "" } - guard let loginName = loginName?.trimmingCharacters(in: .whitespacesAndNewlines), - !loginName.isEmpty, - !trimmedDestination.contains("@") else { - return trimmedDestination - } - return "\(loginName)@\(trimmedDestination)" - } - - private static func sshOptionValue(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if let equalIndex = trimmed.firstIndex(of: "=") { - let value = trimmed[trimmed.index(after: equalIndex)...].trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - let parts = trimmed.split(maxSplits: 1, whereSeparator: \.isWhitespace) - guard parts.count == 2 else { return nil } - let value = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } -} diff --git a/Sources/V2CommandCatalog.swift b/Sources/V2CommandCatalog.swift index f8a0cee1..f6ae3c27 100644 --- a/Sources/V2CommandCatalog.swift +++ b/Sources/V2CommandCatalog.swift @@ -42,12 +42,6 @@ enum V2CommandCatalog { "workspace.previous", "workspace.last", "workspace.equalize_splits", - "workspace.remote.configure", - "workspace.remote.foreground_auth_ready", - "workspace.remote.reconnect", - "workspace.remote.disconnect", - "workspace.remote.status", - "workspace.remote.terminal_session_end", "workspace.set_status", "workspace.clear_status", "workspace.list_status", diff --git a/Sources/VSCodeIntegration.swift b/Sources/VSCodeIntegration.swift deleted file mode 100644 index 763f83f4..00000000 --- a/Sources/VSCodeIntegration.swift +++ /dev/null @@ -1,610 +0,0 @@ -import AppKit -import SwiftUI -import Bonsplit -import CoreServices -import UserNotifications -import WebKit -import Combine -import ObjectiveC.runtime -import Darwin - -enum VSCodeServeWebURLBuilder { - static func extractWebUIURL(from output: String) -> URL? { - let prefix = "Web UI available at " - for line in output.split(whereSeparator: \.isNewline).reversed() { - guard let range = line.range(of: prefix) else { continue } - let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } - return url - } - return nil - } - - static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { - var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) - var queryItems = components?.queryItems ?? [] - queryItems.removeAll { $0.name == "folder" } - queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) - components?.queryItems = queryItems - return components?.url - } -} - -struct VSCodeCLILaunchConfiguration { - let executableURL: URL - let argumentsPrefix: [String] - let environment: [String: String] -} - -enum VSCodeCLILaunchConfigurationBuilder { - static func launchConfiguration( - vscodeApplicationURL: URL, - baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, - isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } - ) -> VSCodeCLILaunchConfiguration? { - let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) - let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) - guard isExecutableAtPath(codeTunnelURL.path) else { return nil } - - var environment = baseEnvironment - environment["ELECTRON_RUN_AS_NODE"] = "1" - environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") - environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") - if let nodeOptions = environment["NODE_OPTIONS"] { - environment["VSCODE_NODE_OPTIONS"] = nodeOptions - } - if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { - environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule - } - environment.removeValue(forKey: "NODE_OPTIONS") - environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") - environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" - - return VSCodeCLILaunchConfiguration( - executableURL: codeTunnelURL, - argumentsPrefix: [], - environment: environment - ) - } -} - -final class VSCodeServeWebController { - static let shared = VSCodeServeWebController() - private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 - - private let queue = DispatchQueue(label: "programa.vscode.serveWeb") - private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") - private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? - private var serveWebProcess: Process? - private var launchingProcess: Process? - private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] - private var serveWebURL: URL? - private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] - private var isLaunching = false - private var activeLaunchGeneration: UInt64? - private var lifecycleGeneration: UInt64 = 0 -#if DEBUG - private var testingTrackedProcesses: [Process] = [] -#endif - - private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { - self.launchProcessOverride = launchProcessOverride - } - -#if DEBUG - static func makeForTesting( - launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? - ) -> VSCodeServeWebController { - VSCodeServeWebController(launchProcessOverride: launchProcessOverride) - } - - func trackConnectionTokenFileForTesting( - _ connectionTokenFileURL: URL, - setAsLaunchingProcess: Bool = false, - setAsServeWebProcess: Bool = false - ) { - let process = Process() - queue.sync { - if setAsLaunchingProcess { - self.launchingProcess = process - } - if setAsServeWebProcess { - self.serveWebProcess = process - } - if !setAsLaunchingProcess && !setAsServeWebProcess { - self.testingTrackedProcesses.append(process) - } - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL - } - } -#endif - - func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - queue.async { - if let process = self.serveWebProcess, - process.isRunning, - let url = self.serveWebURL { - DispatchQueue.main.async { - completion(url) - } - return - } - - let completionGeneration = self.lifecycleGeneration - self.pendingCompletions.append((generation: completionGeneration, completion: completion)) - guard !self.isLaunching else { return } - - self.isLaunching = true - let launchGeneration = completionGeneration - self.activeLaunchGeneration = launchGeneration - - self.launchQueue.async { - let shouldLaunch = self.queue.sync { - self.lifecycleGeneration == launchGeneration - } - guard shouldLaunch else { - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { return } - self.isLaunching = false - self.activeLaunchGeneration = nil - } - return - } - let launchResult = self.launchServeWebProcess( - vscodeApplicationURL: vscodeApplicationURL, - expectedGeneration: launchGeneration - ) - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } - self.isLaunching = false - self.activeLaunchGeneration = nil - - guard self.lifecycleGeneration == launchGeneration else { - if let launchedProcess = launchResult?.process, - self.launchingProcess === launchedProcess { - self.launchingProcess = nil - } - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } - - if let launchResult { - self.launchingProcess = nil - self.serveWebProcess = launchResult.process - self.serveWebURL = launchResult.url - } else { - self.launchingProcess = nil - self.serveWebProcess = nil - self.serveWebURL = nil - } - - var completions: [(URL?) -> Void] = [] - var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] - for pending in self.pendingCompletions { - if pending.generation == launchGeneration { - completions.append(pending.completion) - } else { - remaining.append(pending) - } - } - self.pendingCompletions = remaining - let resolvedURL = self.serveWebURL - DispatchQueue.main.async { - completions.forEach { $0(resolvedURL) } - } - } - } - } - } - - func stop() { - let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { - self.lifecycleGeneration &+= 1 - self.isLaunching = false - self.activeLaunchGeneration = nil - var processes: [Process] = [] - if let process = self.serveWebProcess { - processes.append(process) - } - if let process = self.launchingProcess, - !processes.contains(where: { $0 === process }) { - processes.append(process) - } - self.serveWebProcess = nil - self.launchingProcess = nil -#if DEBUG - self.testingTrackedProcesses.removeAll() -#endif - var tokenFileURLs = processes.compactMap { - self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) - } - tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) - self.connectionTokenFilesByProcessID.removeAll() - self.serveWebURL = nil - let completions = self.pendingCompletions.map(\.completion) - self.pendingCompletions.removeAll() - return (processes, tokenFileURLs, completions) - } - - for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - - for process in processes where process.isRunning { - process.terminate() - } - - if !completions.isEmpty { - DispatchQueue.main.async { - completions.forEach { $0(nil) } - } - } - } - - func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - stop() - ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) - } - - private func launchServeWebProcess( - vscodeApplicationURL: URL, - expectedGeneration: UInt64 - ) -> (process: Process, url: URL)? { - if let launchProcessOverride { - return launchProcessOverride(vscodeApplicationURL, expectedGeneration) - } - - guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: vscodeApplicationURL - ) else { return nil } - - guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { - return nil - } - - let process = Process() - process.executableURL = launchConfiguration.executableURL - // #21: reuse the port VS Code assigned on a previous run so the embedded browser - // keeps the same URL across restarts. ServeWebPortStore returns the persisted port - // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied - // port falls back gracefully instead of failing the launch. The --server-data-dir - // and persistent connection-token already fix Settings Sync / OAuth auth. - process.arguments = launchConfiguration.argumentsPrefix + [ - "serve-web", - "--accept-server-license-terms", - "--host", "127.0.0.1", - "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), - "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), - "--connection-token-file", connectionTokenFileURL.path, - ] - process.environment = launchConfiguration.environment - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - let collector = ServeWebOutputCollector() - let outputReader: (FileHandle) -> Void = { fileHandle in - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) - } - stdoutPipe.fileHandleForReading.readabilityHandler = outputReader - stderrPipe.fileHandleForReading.readabilityHandler = outputReader - - process.terminationHandler = { [weak self] terminatedProcess in - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) - Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) - collector.markProcessExited() - self?.queue.async { - guard let self else { return } - if self.launchingProcess === terminatedProcess { - self.launchingProcess = nil - } - if self.serveWebProcess === terminatedProcess { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(terminatedProcess) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } - } - - let didStart: Bool = queue.sync { - guard self.lifecycleGeneration == expectedGeneration, - self.activeLaunchGeneration == expectedGeneration else { - return false - } - self.launchingProcess = process - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL - do { - try process.run() - return true - } catch { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - return false - } - } - guard didStart else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: connectionTokenFileURL) - } - return nil - } - - guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), - let serveWebURL = collector.webUIURL else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if process.isRunning { - process.terminate() - } else { - queue.sync { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if self.serveWebProcess === process { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } - } - return nil - } - - // #21: remember the assigned port so the next launch can request it again. - if let assignedPort = serveWebURL.port { - ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) - } - - return (process, serveWebURL) - } - - private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { - while true { - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) - } - } - - /// Stable Application Support directory for VS Code serve-web state. - /// Mirrors the "programa" subdirectory convention used elsewhere in the app. - private static var vscodeServerDataDir: URL? { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("vscode-server", isDirectory: true) - } - - private static func randomConnectionToken() -> String { - UUID().uuidString.replacingOccurrences(of: "-", with: "") - } - - /// Returns a URL for the connection token file. Prefers a persistent file - /// under Application Support so the token (and the browser's vscode-tkn - /// cookie) survives Programa restarts (issue #21). Falls back to an - /// ephemeral temp-dir file when Application Support is unavailable. - private static func makeConnectionTokenFile() -> URL? { - if let persistentURL = vscodeServerDataDir? - .appendingPathComponent("connection-token", isDirectory: false) { - if let url = makePersistentConnectionTokenFile(at: persistentURL) { - return url - } - } - return makeEphemeralConnectionTokenFile() - } - - private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { - let dir = tokenFileURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // Reuse an existing non-empty token so the browser cookie stays valid. - if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { - return tokenFileURL - } - let token = randomConnectionToken() - guard let tokenData = token.data(using: .utf8) else { return nil } - let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) - guard fd >= 0 else { return nil } - defer { _ = close(fd) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count - } - guard wroteAllBytes else { - try? FileManager.default.removeItem(at: tokenFileURL) - return nil - } - return tokenFileURL - } - - private static func makeEphemeralConnectionTokenFile() -> URL? { - let token = randomConnectionToken() - let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" - let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(tokenFileName, isDirectory: false) - guard let tokenData = token.data(using: .utf8) else { return nil } - let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) - guard fileDescriptor >= 0 else { return nil } - defer { _ = close(fileDescriptor) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count - } - guard wroteAllBytes else { - removeConnectionTokenFile(at: tokenFileURL) - return nil - } - return tokenFileURL - } - - private static func removeConnectionTokenFile(at url: URL) { - try? FileManager.default.removeItem(at: url) - } -} - -final class ServeWebOutputCollector { - static let defaultMaximumBytes = 1024 * 1024 - - private let lock = NSLock() - private let semaphore = DispatchSemaphore(value: 0) - private let maximumBytes: Int - private var outputBuffer = "" - private var resolvedURL: URL? - private var didSignal = false - private var receivedByteCount = 0 - private(set) var didOverflow = false - - init(maximumBytes: Int = ServeWebOutputCollector.defaultMaximumBytes) { - self.maximumBytes = max(0, maximumBytes) - } - - var webUIURL: URL? { - lock.lock() - defer { lock.unlock() } - return resolvedURL - } - - func append(_ data: Data) { - lock.lock() - defer { lock.unlock() } - guard resolvedURL == nil, !didOverflow else { return } - guard data.count <= maximumBytes - receivedByteCount else { - didOverflow = true - outputBuffer.removeAll(keepingCapacity: false) - signalWaiterIfNeeded() - return - } - receivedByteCount += data.count - guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } - outputBuffer.append(text) - while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { - let line = String(outputBuffer[..<newlineIndex]) - outputBuffer.removeSubrange(...newlineIndex) - guard let parsedURL = VSCodeServeWebURLBuilder.extractWebUIURL(from: line) else { - continue - } - resolvedURL = parsedURL - outputBuffer.removeAll(keepingCapacity: false) - signalWaiterIfNeeded() - return - } - } - - func markProcessExited() { - lock.lock() - defer { lock.unlock() } - if resolvedURL == nil, !outputBuffer.isEmpty, - let parsedURL = VSCodeServeWebURLBuilder.extractWebUIURL(from: outputBuffer) { - resolvedURL = parsedURL - outputBuffer.removeAll(keepingCapacity: false) - } - signalWaiterIfNeeded() - } - - func waitForURL(timeoutSeconds: TimeInterval) -> Bool { - if webUIURL != nil { return true } - _ = semaphore.wait(timeout: .now() + timeoutSeconds) - return webUIURL != nil - } - - private func signalWaiterIfNeeded() { - guard !didSignal else { return } - didSignal = true - semaphore.signal() - } -} - -/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser -/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web -/// data dir and reused on the next launch — but only when it is still bindable, so a -/// now-occupied port falls back to an OS-assigned one instead of failing the launch. -enum ServeWebPortStore { - static let fileName = "serve-web-port" - - /// The `--port` argument for `code serve-web`: the persisted port when it is valid and - /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable - /// for testing; it defaults to a real loopback bind probe. - static func portArgument( - persistedIn directory: URL?, - isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable - ) -> String { - guard let url = portFileURL(in: directory), - let data = try? Data(contentsOf: url), - let raw = String(data: data, encoding: .utf8), - let port = parsePort(raw), - isPortAvailable(port) else { - return "0" - } - return String(port) - } - - /// Records the port VS Code assigned so the next launch can request it again. No-op for - /// out-of-range ports or when the data dir is unavailable. - static func persist(port: Int, in directory: URL?) { - guard isValidPort(port), let url = portFileURL(in: directory) else { return } - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? Data(String(port).utf8).write(to: url, options: .atomic) - } - - /// Parses a stored port string, returning nil for malformed or out-of-range values. - static func parsePort(_ raw: String) -> Int? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let port = Int(trimmed), isValidPort(port) else { return nil } - return port - } - - /// True when a TCP socket can bind 127.0.0.1:port right now. - static func isPortAvailable(_ port: Int) -> Bool { - guard isValidPort(port) else { return false } - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - var reuse: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size)) - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(port)).bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - let bound = withUnsafePointer(to: &addr) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - bind(fd, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size)) - } - } - return bound == 0 - } - - private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } - - private static func portFileURL(in directory: URL?) -> URL? { - directory?.appendingPathComponent(fileName, isDirectory: false) - } -} diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index 3f4367d8..6c0d9ea8 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -62,7 +62,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let showsGitBranch: Bool let usesVerticalBranchLayout: Bool let showsGitBranchIcon: Bool - let showsSSH: Bool let openPullRequestLinksInProgramaBrowser: Bool let openPortLinksInProgramaBrowser: Bool let showsNotificationMessage: Bool @@ -90,7 +89,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { showsGitBranch = Self.bool(defaults: defaults, key: "sidebarShowGitBranch", defaultValue: true) usesVerticalBranchLayout = true showsGitBranchIcon = Self.bool(defaults: defaults, key: "sidebarShowGitBranchIcon", defaultValue: false) - showsSSH = true openPullRequestLinksInProgramaBrowser = true openPortLinksInProgramaBrowser = true showsNotificationMessage = true @@ -227,12 +225,6 @@ struct VerticalTabsSidebar: View { }) let orderedSelectedTabs = tabs.filter { selectedTabIds.contains($0.id) } let selectedContextTargetIds = orderedSelectedTabs.map(\.id) - let selectedRemoteContextMenuTargets = orderedSelectedTabs.filter { $0.isRemoteWorkspace } - let selectedRemoteContextMenuWorkspaceIds = selectedRemoteContextMenuTargets.map(\.id) - let allSelectedRemoteContextMenuTargetsConnecting = !selectedRemoteContextMenuTargets.isEmpty && - selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting } - let allSelectedRemoteContextMenuTargetsDisconnected = !selectedRemoteContextMenuTargets.isEmpty && - selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected } let sidebarContent = VStack(spacing: 0) { // Single header row shared with the traffic lights (Maps-style): the // lights float over its leading side, controls sit trailing, and the @@ -269,15 +261,6 @@ struct VerticalTabsSidebar: View { let contextMenuWorkspaceIds = usesSelectedContextMenuTargets ? selectedContextTargetIds : [tab.id] - let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedRemoteContextMenuWorkspaceIds - : (tab.isRemoteWorkspace ? [tab.id] : []) - let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsConnecting - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) - let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsDisconnected - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) let showsWorktreeBadge = tab.worktreeParentWorkspaceId != nil let worktreeChildCount = SidebarWorkspaceHierarchy.childCount( of: tab.id, @@ -317,9 +300,6 @@ struct VerticalTabsSidebar: View { draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, contextMenuWorkspaceIds: contextMenuWorkspaceIds, - remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, - allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, - allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, settings: tabItemSettings, showsWorktreeBadge: showsWorktreeBadge, isWorktreeFolder: tab.isWorktreeFolder, diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index d1aa1757..5d84ceec 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -215,13 +215,6 @@ extension Workspace: @preconcurrency BonsplitDelegate { p.unfocus() } - // Web extensions track the active tab through this same selection funnel - // (didFocusPane also lands here), so this one hook covers pane focus and - // tab selection alike. - if #available(macOS 15.4, *), let browserPanel = panel as? BrowserPanel { - BrowserExtensionManager.shared.noteTabActivated(browserPanel) - } - // Explicitly hide browser portals for deselected tabs in this pane. // Bonsplit's keepAllAlive mode hides non-selected tabs via SwiftUI .opacity(0), // but portal-hosted WKWebViews render at the window level in AppKit and are not @@ -588,7 +581,6 @@ extension Workspace: @preconcurrency BonsplitDelegate { #endif let panel = panels[panelId] - let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) if isDetaching, let panel { let browserPanel = panel as? BrowserPanel @@ -608,12 +600,7 @@ extension Workspace: @preconcurrency BonsplitDelegate { ttyName: surfaceTTYNames[panelId], cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId), - isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), - remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) - ? remoteConfiguration?.relayPort - : nil, - remoteCleanupConfiguration: transferredRemoteCleanupConfiguration + manuallyUnread: manualUnreadPanelIds.contains(panelId) ) if isUndoStaging, let originalIndex = undoStageOriginalIndex, let staged = pendingDetachedSurfaces.removeValue(forKey: tabId) { @@ -631,16 +618,9 @@ extension Workspace: @preconcurrency BonsplitDelegate { } panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) surfaceIdToPanelId.removeValue(forKey: tabId) removeSurfaceMetadata(panelId: panelId, isDetaching: isDetaching) - syncRemotePortScanTTYs() recomputeListeningPorts() - clearRemoteConfigurationIfWorkspaceBecameLocal() - if !isDetaching, let transferredRemoteCleanupConfiguration { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: transferredRemoteCleanupConfiguration) - } // Keep the workspace invariant for normal close paths. // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can @@ -765,8 +745,8 @@ extension Workspace: @preconcurrency BonsplitDelegate { /// Canonical per-panel metadata teardown shared by every close path. /// The single-surface and pane-close paths previously hand-copied this /// list and drifted (pane close leaked inheritance font points and never - /// cleared notifications). Aggregate recomputes (syncRemotePortScanTTYs, - /// recomputeListeningPorts) stay at the call sites. + /// cleared notifications). Aggregate recomputes (recomputeListeningPorts) + /// stay at the call sites. private func removeSurfaceMetadata(panelId: UUID, isDetaching: Bool = false) { panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) @@ -808,16 +788,12 @@ extension Workspace: @preconcurrency BonsplitDelegate { for panelId in closedPanelIds { panels[panelId]?.close() panels.removeValue(forKey: panelId) - untrackRemoteTerminalSurface(panelId) - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) removeSurfaceMetadata(panelId: panelId) } - syncRemotePortScanTTYs() let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() - clearRemoteConfigurationIfWorkspaceBecameLocal() if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { diff --git a/Sources/Workspace+Persistence.swift b/Sources/Workspace+Persistence.swift index 6b283789..554d70ff 100644 --- a/Sources/Workspace+Persistence.swift +++ b/Sources/Workspace+Persistence.swift @@ -11,26 +11,6 @@ import Network import CoreText extension Workspace { - nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey - - nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { - WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) - } - - nonisolated static func remoteDaemonCachedBinaryURL( - version: String, - goOS: String, - goArch: String, - fileManager: FileManager = .default - ) throws -> URL { - try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( - version: version, - goOS: goOS, - goArch: goArch, - fileManager: fileManager - ) - } - func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -304,12 +284,7 @@ extension Workspace { let branchSnapshot = panelGitBranches[panelId].map { SessionGitBranchSnapshot(branch: $0.branch, isDirty: $0.isDirty) } - let listeningPorts: [Int] - if remoteDetectedSurfaceIds.contains(panelId) || isRemoteTerminalSurface(panelId) { - listeningPorts = [] - } else { - listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() - } + let listeningPorts = (surfaceListeningPorts[panelId] ?? []).sorted() let ttyName = surfaceTTYNames[panelId] let terminalSnapshot: SessionTerminalPanelSnapshot? @@ -790,7 +765,6 @@ extension Workspace { } else { surfaceTTYNames.removeValue(forKey: panelId) } - syncRemotePortScanTTYs() if let browserSnapshot = snapshot.browser, let browserPanel = browserPanel(for: panelId) { diff --git a/Sources/Workspace+Remote.swift b/Sources/Workspace+Remote.swift deleted file mode 100644 index 4cf4086e..00000000 --- a/Sources/Workspace+Remote.swift +++ /dev/null @@ -1,706 +0,0 @@ -// Extracted from Workspace.swift (nuclear-review #98): remote-connection glue members -// (configure/reconnect/disconnect/status plumbing). - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -extension Workspace { - var isRemoteWorkspace: Bool { - remoteConfiguration != nil - } - - /// Whether this workspace is a *live* remote session right now -- connected, connecting, or - /// erroring while still configured -- as opposed to merely having been configured for remote - /// at some point. `remoteConfiguration` intentionally survives a user-initiated disconnect - /// (see `disconnectRemoteConnection(clearConfiguration:)`, whose default and the sidebar's - /// disconnect action both pass `false`) so `reconnectRemoteConnection()` still has something - /// to reconnect to. That means `isRemoteWorkspace` alone is NOT a safe proxy for "this - /// workspace's panels currently live on a remote host" -- a disconnected-but-configured - /// workspace is, for every practical purpose (running shells, port telemetry, session - /// persistence), a local workspace again. Use this property anywhere that distinction - /// matters; use `isRemoteWorkspace` only for "has a remote destination configured" facts - /// (e.g. whether the sidebar should offer Reconnect). - var isLiveRemoteWorkspace: Bool { - isRemoteWorkspace && remoteConnectionState != .disconnected - } - - @MainActor - func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { - activeRemoteTerminalSurfaceIds.contains(panelId) - } - - @MainActor - func shouldDemoteWorkspaceAfterChildExit(surfaceId: UUID) -> Bool { - isRemoteWorkspace || pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) - } - - var remoteDisplayTarget: String? { - remoteConfiguration?.displayTarget - } - - var hasActiveRemoteTerminalSessions: Bool { - activeRemoteTerminalSessionCount > 0 - } - - @MainActor - func uploadDroppedFilesForRemoteTerminal( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation, - completion: @escaping (Result<[String], Error>) -> Void - ) { - guard let controller = remoteSessionController else { - completion(.failure(RemoteDropUploadError.unavailable)) - return - } - controller.uploadDroppedFiles(fileURLs, operation: operation, completion: completion) - } - - @MainActor - func uploadDroppedFilesForRemoteTerminal( - _ fileURLs: [URL], - completion: @escaping (Result<[String], Error>) -> Void - ) { - uploadDroppedFilesForRemoteTerminal( - fileURLs, - operation: TerminalImageTransferOperation(), - completion: completion - ) - } - - func syncRemotePortScanTTYs() { - guard isRemoteWorkspace else { return } - remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames) - } - - func kickRemotePortScan(panelId: UUID, reason: WorkspaceRemoteSessionController.PortScanKickReason = .command) { - guard isRemoteWorkspace else { return } - syncRemotePortScanTTYs() - remoteSessionController?.kickRemotePortScan(panelId: panelId, reason: reason) - } - - func remoteStatusPayload() -> [String: Any] { - let heartbeatAgeSeconds: Any = { - guard let last = remoteLastHeartbeatAt else { return NSNull() } - return max(0, Date().timeIntervalSince(last)) - }() - let heartbeatTimestamp: Any = { - guard let last = remoteLastHeartbeatAt else { return NSNull() } - return Self.remoteHeartbeatDateFormatter.string(from: last) - }() - var payload: [String: Any] = [ - "enabled": remoteConfiguration != nil, - "state": remoteConnectionState.rawValue, - "connected": remoteConnectionState == .connected, - "active_terminal_sessions": activeRemoteTerminalSessionCount, - "daemon": remoteDaemonStatus.payload(), - "detected_ports": remoteDetectedPorts, - "forwarded_ports": remoteForwardedPorts, - "conflicted_ports": remotePortConflicts, - "detail": remoteConnectionDetail ?? NSNull(), - "heartbeat": [ - "count": remoteHeartbeatCount, - "last_seen_at": heartbeatTimestamp, - "age_seconds": heartbeatAgeSeconds, - ], - ] - if let endpoint = remoteProxyEndpoint { - payload["proxy"] = [ - "state": "ready", - "host": endpoint.host, - "port": endpoint.port, - "schemes": ["socks5", "http_connect"], - "url": "socks5://\(endpoint.host):\(endpoint.port)", - ] - } else { - let proxyState: String - if hasProxyOnlyRemoteSidebarError { - proxyState = "error" - } else { - switch remoteConnectionState { - case .connecting: - proxyState = "connecting" - case .error: - proxyState = "error" - default: - proxyState = "unavailable" - } - } - payload["proxy"] = [ - "state": proxyState, - "host": NSNull(), - "port": NSNull(), - "schemes": ["socks5", "http_connect"], - "url": NSNull(), - "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), - ] - } - if let remoteConfiguration { - payload["destination"] = remoteConfiguration.destination - payload["port"] = remoteConfiguration.port ?? NSNull() - payload["has_identity_file"] = remoteConfiguration.identityFile != nil - payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty - payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() - } else { - payload["destination"] = NSNull() - payload["port"] = NSNull() - payload["has_identity_file"] = false - payload["has_ssh_options"] = false - payload["local_proxy_port"] = NSNull() - } - return payload - } - - func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { - // Capture before resetRemoteState() nulls pendingRemoteForegroundAuthToken. - let foregroundAuthToken = Self.normalizedForegroundAuthToken(configuration.foregroundAuthToken) - let shouldAutoConnect = - autoConnect - || (foregroundAuthToken != nil && foregroundAuthToken == pendingRemoteForegroundAuthToken) - - remoteConfiguration = configuration - resetRemoteState() - // Seed after the reset so a reconfigure of an already-connected workspace doesn't see - // stale per-panel bookkeeping from the previous destination and skip seeding. Refs #83. - seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) - recomputeListeningPorts() - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - - guard shouldAutoConnect else { - remoteConnectionState = .disconnected - applyBrowserRemoteWorkspaceStatusToPanels() - return - } - - remoteConnectionState = .connecting - applyBrowserRemoteWorkspaceStatusToPanels() - let controllerID = UUID() - let controller = WorkspaceRemoteSessionController( - workspace: self, - configuration: configuration, - controllerID: controllerID - ) - activeRemoteSessionControllerID = controllerID - remoteSessionController = controller - syncRemotePortScanTTYs() - controller.start() - } - - func reconnectRemoteConnection() { - guard let configuration = remoteConfiguration else { return } - configureRemoteConnection(configuration, autoConnect: true) - } - - static func normalizedForegroundAuthToken(_ token: String?) -> String? { - guard let token else { return nil } - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - func notifyRemoteForegroundAuthenticationReady(token: String? = nil) { - guard let foregroundAuthToken = Self.normalizedForegroundAuthToken(token) else { - return - } - - guard let remoteConfiguration else { - pendingRemoteForegroundAuthToken = foregroundAuthToken - return - } - - guard Self.normalizedForegroundAuthToken(remoteConfiguration.foregroundAuthToken) == foregroundAuthToken else { - return - } - - pendingRemoteForegroundAuthToken = nil - guard remoteConnectionState == .disconnected else { return } - reconnectRemoteConnection() - } - - func disconnectRemoteConnection(clearConfiguration: Bool = false) { - let shouldCleanupControlMaster = - clearConfiguration - && !isDetachingCloseTransaction - && pendingDetachedSurfaces.isEmpty - && !skipControlMasterCleanupAfterDetachedRemoteTransfer - let configurationForCleanup = shouldCleanupControlMaster ? remoteConfiguration : nil - resetRemoteState() - remoteConnectionState = .disconnected - if clearConfiguration { - remoteConfiguration = nil - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - } - applyRemoteProxyEndpointUpdate(nil) - applyBrowserRemoteWorkspaceStatusToPanels() - recomputeListeningPorts() - if let configurationForCleanup { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: configurationForCleanup) - } - } - - /// Resets all per-connection and per-panel remote-session bookkeeping. This is the - /// complete union of what `configureRemoteConnection` and `disconnectRemoteConnection` - /// each need to clear before establishing (or tearing down) a remote connection, so a - /// reconfigure to a new destination can't leave stale state from the previous one. Refs #83. - func resetRemoteState() { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - let previousController = remoteSessionController - activeRemoteSessionControllerID = nil - remoteSessionController = nil - previousController?.stop() - pendingRemoteForegroundAuthToken = nil - activeRemoteTerminalSurfaceIds.removeAll() - activeRemoteTerminalSessionCount = 0 - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - pendingRemoteSurfacePortKickReason = nil - pendingRemoteSurfacePortKickSurfaceId = nil - clearRemoteDetectedSurfacePorts() - remoteDetectedPorts = [] - remoteForwardedPorts = [] - remotePortConflicts = [] - remoteProxyEndpoint = nil - remoteHeartbeatCount = 0 - remoteLastHeartbeatAt = nil - remoteConnectionDetail = nil - remoteDaemonStatus = WorkspaceRemoteDaemonStatus() - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastErrorFingerprint = nil - remoteLastDaemonErrorFingerprint = nil - remoteLastPortConflictFingerprint = nil - } - - func clearRemoteConfigurationIfWorkspaceBecameLocal() { - guard !isDetachingCloseTransaction, panels.isEmpty, remoteConfiguration != nil else { return } - disconnectRemoteConnection(clearConfiguration: true) - } - - func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { - return - } - guard activeRemoteTerminalSurfaceIds.isEmpty else { return } - let terminalIds = panels.compactMap { panelId, panel in - panel is TerminalPanel ? panelId : nil - } - guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } - trackRemoteTerminalSurface(initialPanelId) - } - - func trackRemoteTerminalSurface(_ panelId: UUID) { - skipControlMasterCleanupAfterDetachedRemoteTransfer = false - pendingRemoteTerminalChildExitSurfaceIds.remove(panelId) - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) - guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } - activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count - applyPendingRemoteSurfaceTTYIfNeeded(to: panelId) - _ = applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) - } - - func untrackRemoteTerminalSurface(_ panelId: UUID) { - guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } - activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count - guard !isDetachingCloseTransaction else { return } - maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() - } - - func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { - guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } - let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } - if !hasBrowserPanels { - if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { - return - } - disconnectRemoteConnection(clearConfiguration: true) - } - } - - @MainActor - func rememberPendingRemoteSurfaceTTY(_ ttyName: String, requestedSurfaceId: UUID?) { - guard let normalizedTTY = normalizedSidebarTTYName(ttyName) else { return } - pendingRemoteSurfaceTTYName = normalizedTTY - pendingRemoteSurfaceTTYSurfaceId = requestedSurfaceId - } - - @MainActor - func rememberPendingRemoteSurfacePortKick( - reason: WorkspaceRemoteSessionController.PortScanKickReason, - requestedSurfaceId: UUID? - ) { - pendingRemoteSurfacePortKickReason = reason - pendingRemoteSurfacePortKickSurfaceId = requestedSurfaceId - } - - @MainActor - func applyPendingRemoteSurfaceTTYIfNeeded(to panelId: UUID) { - guard let pendingTTYName = pendingRemoteSurfaceTTYName else { return } - if let requestedSurfaceId = pendingRemoteSurfaceTTYSurfaceId, requestedSurfaceId != panelId { - return - } - guard setSidebarTTYName(panelId: panelId, ttyName: pendingTTYName) else { - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - return - } - pendingRemoteSurfaceTTYName = nil - pendingRemoteSurfaceTTYSurfaceId = nil - syncRemotePortScanTTYs() - if !applyPendingRemoteSurfacePortKickIfNeeded(to: panelId) { - kickRemotePortScan(panelId: panelId, reason: .command) - } - } - - @MainActor - @discardableResult - func applyPendingRemoteSurfacePortKickIfNeeded(to panelId: UUID) -> Bool { - guard let reason = pendingRemoteSurfacePortKickReason else { - return false - } - if let requestedSurfaceId = pendingRemoteSurfacePortKickSurfaceId, - requestedSurfaceId != panelId { - return false - } - guard let ttyName = surfaceTTYNames[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines), - !ttyName.isEmpty else { - return false - } - _ = ttyName - pendingRemoteSurfacePortKickReason = nil - pendingRemoteSurfacePortKickSurfaceId = nil - kickRemotePortScan(panelId: panelId, reason: reason) - return true - } - - @MainActor - func applyBootstrapRemoteTTY(_ ttyName: String) { - guard let normalizedTTY = normalizedSidebarTTYName(ttyName) else { return } - - let candidateSurfaceId: UUID? = { - if let focusedPanelId, activeRemoteTerminalSurfaceIds.contains(focusedPanelId) { - return focusedPanelId - } - if activeRemoteTerminalSurfaceIds.count == 1 { - return activeRemoteTerminalSurfaceIds.first - } - return nil - }() - - guard let candidateSurfaceId else { - rememberPendingRemoteSurfaceTTY(normalizedTTY, requestedSurfaceId: nil) - return - } - - guard setSidebarTTYName(panelId: candidateSurfaceId, ttyName: normalizedTTY) else { return } - syncRemotePortScanTTYs() - if !applyPendingRemoteSurfacePortKickIfNeeded(to: candidateSurfaceId) { - kickRemotePortScan(panelId: candidateSurfaceId, reason: .command) - } - } - - func cleanupTransferredRemoteConnectionIfNeeded(surfaceId: UUID, relayPort: Int?) -> Bool { - guard let relayPort, - relayPort > 0, - let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[surfaceId], - cleanupConfiguration.relayPort == relayPort else { - return false - } - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: surfaceId) - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - return true - } - - func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { - if cleanupTransferredRemoteConnectionIfNeeded(surfaceId: surfaceId, relayPort: relayPort) { - return - } - guard let relayPort, - relayPort > 0, - remoteConfiguration?.relayPort == relayPort else { - return - } - pendingRemoteTerminalChildExitSurfaceIds.insert(surfaceId) - untrackRemoteTerminalSurface(surfaceId) - } - - func teardownRemoteConnection() { - disconnectRemoteConnection(clearConfiguration: true) - } - - static func requestSSHControlMasterCleanupIfNeeded(configuration: WorkspaceRemoteConfiguration) { - guard let arguments = sshControlMasterCleanupArguments(configuration: configuration) else { return } - if let override = runSSHControlMasterCommandOverrideForTesting { - override(arguments) - return - } - - sshControlMasterCleanupQueue.async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = arguments - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in - exitSemaphore.signal() - } - - do { - try process.run() - if exitSemaphore.wait(timeout: .now() + 5) == .timedOut { - if process.isRunning { - process.terminate() - } - _ = exitSemaphore.wait(timeout: .now() + 1) - } - } catch { - return - } - } - } - - static func sshControlMasterCleanupArguments(configuration: WorkspaceRemoteConfiguration) -> [String]? { - let sshOptions = normalizedSSHControlCleanupOptions(configuration.sshOptions) - var arguments: [String] = [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - ] - if let port = configuration.port { - arguments += ["-p", String(port)] - } - if let identityFile = configuration.identityFile?.trimmingCharacters(in: .whitespacesAndNewlines), - !identityFile.isEmpty { - arguments += ["-i", identityFile] - } - for option in sshOptions { - arguments += ["-o", option] - } - arguments += ["-O", "exit", configuration.destination] - return arguments - } - - static func normalizedSSHControlCleanupOptions(_ options: [String]) -> [String] { - let disallowedKeys: Set<String> = ["controlmaster", "controlpersist"] - return options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let key = sshOptionKeyForControlCleanup(trimmed) else { return nil } - return disallowedKeys.contains(key) ? nil : trimmed - } - } - - static func sshOptionKeyForControlCleanup(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } - - func applyRemoteConnectionStateUpdate( - _ state: WorkspaceRemoteConnectionState, - detail: String?, - target: String - ) { - let boundedDetail = detail.map { - SidebarTelemetryLimits.truncatedToUTF8Limit( - $0, - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ) - } - let trimmedDetail = boundedDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false - let preserveConnectedStateForRetry = - state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError - let effectiveState: WorkspaceRemoteConnectionState - if state == .error && proxyOnlyError && preservesSSHTerminalConnection { - effectiveState = .connected - } else if preserveConnectedStateForRetry { - effectiveState = .connected - } else { - effectiveState = state - } - - remoteConnectionState = effectiveState - remoteConnectionDetail = boundedDetail - applyBrowserRemoteWorkspaceStatusToPanels() - - if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { - let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" - let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" - let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" - let logSource = proxyOnlyError ? "remote-proxy" : "remote" - let diagnosticMessage = "\(statusPrefix) (\(target)): \(trimmedDetail)" - _ = setSidebarStatusEntry(SidebarStatusEntry( - key: Self.remoteErrorStatusKey, - value: SidebarTelemetryLimits.truncatedToUTF8Limit( - diagnosticMessage, - maxBytes: SidebarTelemetryLimits.maxStatusValueBytes - ), - icon: statusIcon, - color: nil, - timestamp: Date() - )) - - let fingerprint = SidebarTelemetryLimits.truncatedToUTF8Limit( - "connection:\(trimmedDetail)", - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ) - if remoteLastErrorFingerprint != fingerprint { - remoteLastErrorFingerprint = fingerprint - appendSidebarLog( - message: SidebarTelemetryLimits.truncatedToUTF8Limit( - diagnosticMessage, - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ), - level: .error, - source: logSource - ) - AppDelegate.shared?.notificationStore?.addNotification( - tabId: id, - surfaceId: nil, - title: notificationTitle, - subtitle: target, - body: trimmedDetail, - cooldownKey: remoteNotificationCooldownKey(target: target), - cooldownInterval: Self.remoteNotificationCooldown - ) - } - return - } - - if state == .connected { - statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) - remoteLastErrorFingerprint = nil - } - } - - func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { - var boundedStatus = status - boundedStatus.detail = status.detail.map { - SidebarTelemetryLimits.truncatedToUTF8Limit( - $0, - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ) - } - remoteDaemonStatus = boundedStatus - applyBrowserRemoteWorkspaceStatusToPanels() - guard boundedStatus.state == .error else { - remoteLastDaemonErrorFingerprint = nil - return - } - let trimmedDetail = boundedStatus.detail?.trimmingCharacters(in: .whitespacesAndNewlines) - let effectiveDetail: String - if let trimmedDetail, !trimmedDetail.isEmpty { - effectiveDetail = trimmedDetail - } else { - effectiveDetail = "remote daemon error" - } - let fingerprint = SidebarTelemetryLimits.truncatedToUTF8Limit( - "daemon:\(effectiveDetail)", - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ) - guard remoteLastDaemonErrorFingerprint != fingerprint else { return } - remoteLastDaemonErrorFingerprint = fingerprint - appendSidebarLog( - message: SidebarTelemetryLimits.truncatedToUTF8Limit( - "Remote daemon error (\(target)): \(effectiveDetail)", - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ), - level: .error, - source: "remote-daemon" - ) - } - - func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { - remoteProxyEndpoint = endpoint - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - browserPanel.setRemoteProxyEndpoint(endpoint) - } - applyBrowserRemoteWorkspaceStatusToPanels() - } - - func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { - remoteHeartbeatCount = max(0, count) - remoteLastHeartbeatAt = lastSeenAt - applyBrowserRemoteWorkspaceStatusToPanels() - } - - func applyRemoteDetectedSurfacePortsSnapshot( - detectedByPanel: [UUID: [Int]], - detected: [Int], - forwarded: [Int], - conflicts: [Int], - target: String - ) { - let trackedSurfaceIds = Set(detectedByPanel.keys) - for panelId in remoteDetectedSurfaceIds.subtracting(trackedSurfaceIds) { - surfaceListeningPorts.removeValue(forKey: panelId) - } - remoteDetectedSurfaceIds = trackedSurfaceIds - - for (panelId, ports) in detectedByPanel { - if ports.isEmpty { - surfaceListeningPorts.removeValue(forKey: panelId) - } else { - surfaceListeningPorts[panelId] = ports - } - } - - remoteDetectedPorts = detected - remoteForwardedPorts = forwarded - remotePortConflicts = conflicts - recomputeListeningPorts() - - if conflicts.isEmpty { - statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) - remoteLastPortConflictFingerprint = nil - return - } - - let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") - let statusMessage = "SSH port conflicts (\(target)): \(conflictsList)" - _ = setSidebarStatusEntry(SidebarStatusEntry( - key: Self.remotePortConflictStatusKey, - value: SidebarTelemetryLimits.truncatedToUTF8Limit( - statusMessage, - maxBytes: SidebarTelemetryLimits.maxStatusValueBytes - ), - icon: "exclamationmark.triangle.fill", - color: nil, - timestamp: Date() - )) - - let fingerprint = SidebarTelemetryLimits.truncatedToUTF8Limit( - conflicts.map(String.init).joined(separator: ","), - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ) - guard remoteLastPortConflictFingerprint != fingerprint else { return } - remoteLastPortConflictFingerprint = fingerprint - appendSidebarLog( - message: SidebarTelemetryLimits.truncatedToUTF8Limit( - "Port conflicts while forwarding \(target): \(conflictsList)", - maxBytes: SidebarTelemetryLimits.maxLogMessageBytes - ), - level: .warning, - source: "remote-forward" - ) - } - - func clearRemoteDetectedSurfacePorts() { - for panelId in remoteDetectedSurfaceIds { - surfaceListeningPorts.removeValue(forKey: panelId) - } - remoteDetectedSurfaceIds.removeAll() - } -} diff --git a/Sources/Workspace+SidebarTelemetry.swift b/Sources/Workspace+SidebarTelemetry.swift index c3889e28..62372244 100644 --- a/Sources/Workspace+SidebarTelemetry.swift +++ b/Sources/Workspace+SidebarTelemetry.swift @@ -269,7 +269,6 @@ extension Workspace { // this is the single safe place to fire from. AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: state, source: source) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: panelId, state: state, source: source) - MobileBridgePush.shared.noteAgentStateChanged(workspaceId: id, workspaceTitle: title, changedState: state) #if DEBUG dlog( "surface.agentState workspace=\(id.uuidString.prefix(5)) " + @@ -284,7 +283,6 @@ extension Workspace { panelAgentStateSources.removeValue(forKey: panelId) AgentStateWaitRegistry.shared.notify(surfaceId: panelId, newState: nil, source: nil) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: panelId, state: nil, source: nil) - MobileBridgePush.shared.noteAgentStateChanged(workspaceId: id, workspaceTitle: title, changedState: nil) #if DEBUG dlog("surface.agentState.clear workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5))") #endif @@ -319,9 +317,6 @@ extension Workspace { AgentStateWaitRegistry.shared.notify(surfaceId: surfaceId, newState: nil, source: nil) SocketEventBroadcaster.shared.publishAgentState(workspaceId: id, surfaceId: surfaceId, state: nil, source: nil) } - if !clearedAgentSurfaceIds.isEmpty { - MobileBridgePush.shared.noteAgentStateChanged(workspaceId: id, workspaceTitle: title, changedState: nil) - } surfaceListeningPorts.removeAll() listeningPorts.removeAll() metadataBlocks.removeAll() @@ -445,9 +440,6 @@ extension Workspace { if didPruneTTYNames { surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } } - if remoteDetectedSurfaceIds.contains(where: { !validSurfaceIds.contains($0) }) { - remoteDetectedSurfaceIds = remoteDetectedSurfaceIds.filter { validSurfaceIds.contains($0) } - } if panelShellActivityStates.keys.contains(where: { !validSurfaceIds.contains($0) }) { panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) } } @@ -460,9 +452,6 @@ extension Workspace { if panelAgentStateSources.keys.contains(where: { !validSurfaceIds.contains($0) }) { panelAgentStateSources = panelAgentStateSources.filter { validSurfaceIds.contains($0.key) } } - if didPruneTTYNames { - syncRemotePortScanTTYs() - } if didPruneListeningPorts { recomputeListeningPorts() } @@ -471,8 +460,6 @@ extension Workspace { func recomputeListeningPorts() { let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) .union(agentListeningPorts) - .union(remoteDetectedPorts) - .union(remoteForwardedPorts) let next = unique.sorted() if listeningPorts != next { listeningPorts = next @@ -529,13 +516,7 @@ extension Workspace { func sidebarHomeDirectoryForCanonicalization( resolvedPanelDirectories: [UUID: String] ) -> String? { - if isRemoteWorkspace { - return SidebarBranchOrdering.inferredRemoteHomeDirectory( - from: Array(resolvedPanelDirectories.values), - fallbackDirectory: normalizedSidebarDirectory(currentDirectory) - ) - } - return FileManager.default.homeDirectoryForCurrentUser.path + FileManager.default.homeDirectoryForCurrentUser.path } func sidebarResolvedDirectory(for panelId: UUID) -> String? { diff --git a/Sources/Workspace+Surfaces.swift b/Sources/Workspace+Surfaces.swift index 70a11c10..7e920764 100644 --- a/Sources/Workspace+Surfaces.swift +++ b/Sources/Workspace+Surfaces.swift @@ -204,7 +204,6 @@ extension Workspace { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Inherit working directory: prefer the source panel's reported cwd, // then its requested startup cwd if shell integration has not reported @@ -235,15 +234,11 @@ extension Workspace { context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand + portOrdinal: portOrdinal ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { - trackRemoteTerminalSurface(newPanel.id) - } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit @@ -269,9 +264,6 @@ extension Workspace { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) - if remoteTerminalStartupCommand != nil { - untrackRemoteTerminalSurface(newPanel.id) - } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -324,7 +316,6 @@ extension Workspace { let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) - let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create new terminal panel let newPanel = TerminalPanel( @@ -333,7 +324,6 @@ extension Workspace { configTemplate: inheritedConfig, workingDirectory: workingDirectory, portOrdinal: portOrdinal, - initialCommand: remoteTerminalStartupCommand, additionalEnvironment: startupEnvironment, reviveDescriptor: reviveDescriptor, pendingScrollbackSeedText: pendingScrollbackSeedText @@ -341,9 +331,6 @@ extension Workspace { configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle - if remoteTerminalStartupCommand != nil { - trackRemoteTerminalSurface(newPanel.id) - } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit @@ -357,9 +344,6 @@ extension Workspace { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) - if remoteTerminalStartupCommand != nil { - untrackRemoteTerminalSurface(newPanel.id) - } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -390,15 +374,6 @@ extension Workspace { return newPanel } - func remoteTerminalStartupCommand() -> String? { - guard let command = remoteConfiguration?.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines), - !command.isEmpty else { - return nil - } - return command - } - /// Create a new browser panel split @discardableResult func newBrowserSplit( @@ -429,10 +404,7 @@ extension Workspace { preferredProfileID: preferredProfileID, sourcePanelId: panelId ), - initialURL: url, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil + initialURL: url ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -478,7 +450,6 @@ extension Workspace { } installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -508,10 +479,7 @@ extension Workspace { sourcePanelId: sourcePanelId ), initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, - proxyEndpoint: remoteProxyEndpoint, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -557,7 +525,6 @@ extension Workspace { } installBrowserPanelSubscription(browserPanel) - browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8cdaea34..f7d6304c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -150,44 +150,8 @@ final class Workspace: Identifiable, ObservableObject { @Published var panelAgentStateSources: [UUID: AgentStateSource] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] var agentListeningPorts: [Int] = [] - @Published var remoteConfiguration: WorkspaceRemoteConfiguration? - @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected - @Published var remoteConnectionDetail: String? - @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() - @Published var remoteDetectedPorts: [Int] = [] - @Published var remoteForwardedPorts: [Int] = [] - @Published var remotePortConflicts: [Int] = [] - @Published var remoteProxyEndpoint: BrowserProxyEndpoint? - @Published var remoteHeartbeatCount: Int = 0 - @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] - // nuclear-review #98: flipped from `private(set)` to internal so Workspace+Remote.swift - // (a separate file) can mutate this after the remote-connection functions moved there. - @Published var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] - var remoteSessionController: WorkspaceRemoteSessionController? - var pendingRemoteForegroundAuthToken: String? - var activeRemoteSessionControllerID: UUID? - var remoteLastErrorFingerprint: String? - var remoteLastDaemonErrorFingerprint: String? - var remoteLastPortConflictFingerprint: String? - var remoteDetectedSurfaceIds: Set<UUID> = [] - var activeRemoteTerminalSurfaceIds: Set<UUID> = [] - var pendingRemoteTerminalChildExitSurfaceIds: Set<UUID> = [] - - static let remoteErrorStatusKey = "remote.error" - static let remotePortConflictStatusKey = "remote.port_conflicts" - static let remoteNotificationCooldown: TimeInterval = 5 * 60 - static let sshControlMasterCleanupQueue = DispatchQueue( - label: "com.cmux.remote-ssh.control-master-cleanup", - qos: .utility - ) - static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - nonisolated(unsafe) static var runSSHControlMasterCommandOverrideForTesting: (([String]) -> Void)? var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. /// Used for stale-session detection: if the PID is dead, the status entry is cleared. @@ -236,9 +200,6 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($progress), sidebarObservationSignal($panelAgentStates), sidebarObservationSignal($panelAgentStateSources), - sidebarObservationSignal($remoteConnectionState), - sidebarObservationSignal($remoteConnectionDetail), - sidebarObservationSignal($activeRemoteTerminalSessionCount), sidebarObservationSignal($listeningPorts), ] @@ -259,7 +220,6 @@ final class Workspace: Identifiable, ObservableObject { sidebarObservationSignal($panelGitBranches), sidebarObservationSignal($pullRequest), sidebarObservationSignal($panelPullRequests), - sidebarObservationSignal($remoteConfiguration), ] return Publishers.MergeMany(publishers).eraseToAnyPublisher() @@ -276,39 +236,6 @@ final class Workspace: Identifiable, ObservableObject { .eraseToAnyPublisher() }() - static func isProxyOnlyRemoteError(_ detail: String) -> Bool { - let lowered = detail.lowercased() - return lowered.contains("remote proxy") - || lowered.contains("proxy_unavailable") - || lowered.contains("local daemon proxy") - || lowered.contains("proxy failure") - || lowered.contains("daemon transport") - } - - var preservesSSHTerminalConnection: Bool { - activeRemoteTerminalSessionCount > 0 - && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - } - - var hasProxyOnlyRemoteSidebarError: Bool { - guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } - return entry.lowercased().contains("remote proxy unavailable") - } - - func remoteNotificationCooldownKey(target: String) -> String? { - let rawTarget = (remoteConfiguration?.destination ?? target) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawTarget.isEmpty else { return nil } - let normalizedHost = rawTarget - .split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) - .last - .map(String.init)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - guard let normalizedHost, !normalizedHost.isEmpty else { return nil } - return "remote-host:\(normalizedHost)" - } - var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } @@ -556,11 +483,6 @@ final class Workspace: Identifiable, ObservableObject { tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } - deinit { - activeRemoteSessionControllerID = nil - remoteSessionController?.stop() - } - // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID @@ -662,10 +584,6 @@ final class Workspace: Identifiable, ObservableObject { let cachedTitle: String? let customTitle: String? let manuallyUnread: Bool - let isRemoteTerminal: Bool - let remoteRelayPort: Int? - private(set) var remoteConfigurationIdentity: WorkspaceRemoteConfiguration? - private(set) var remoteCleanupConfiguration: WorkspaceRemoteConfiguration? private var state: State = .pending init( @@ -681,10 +599,7 @@ final class Workspace: Identifiable, ObservableObject { ttyName: String?, cachedTitle: String?, customTitle: String?, - manuallyUnread: Bool, - isRemoteTerminal: Bool, - remoteRelayPort: Int?, - remoteCleanupConfiguration: WorkspaceRemoteConfiguration? + manuallyUnread: Bool ) { self.panelId = panelId self.panel = panel @@ -699,24 +614,6 @@ final class Workspace: Identifiable, ObservableObject { self.cachedTitle = cachedTitle self.customTitle = customTitle self.manuallyUnread = manuallyUnread - self.isRemoteTerminal = isRemoteTerminal - self.remoteRelayPort = remoteRelayPort - self.remoteConfigurationIdentity = remoteCleanupConfiguration - self.remoteCleanupConfiguration = remoteCleanupConfiguration - } - - @discardableResult - func withRemoteConfigurationIdentity(_ configuration: WorkspaceRemoteConfiguration?) -> Self { - guard case .pending = state else { return self } - remoteConfigurationIdentity = configuration - return self - } - - @discardableResult - func withRemoteCleanupConfiguration(_ configuration: WorkspaceRemoteConfiguration?) -> Self { - guard case .pending = state else { return self } - remoteCleanupConfiguration = configuration - return self } fileprivate var isPending: Bool { @@ -759,9 +656,6 @@ final class Workspace: Identifiable, ObservableObject { func finalizePermanently() { guard case .pending = state else { return } state = .finalized - if let remoteCleanupConfiguration { - Workspace.requestSSHControlMasterCleanupIfNeeded(configuration: remoteCleanupConfiguration) - } panel.close() TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panelId) } @@ -782,15 +676,6 @@ final class Workspace: Identifiable, ObservableObject { /// Fires from `didCloseTab` when a close marked via `pendingUndoStageOriginalIndex` completes. /// Wired by `TabManager` to route the still-alive detached panel into `closedTerminalUndoStore`. var onTerminalCloseStagedForUndo: ((DetachedSurfaceTransfer, PaneID, Int) -> Void)? - var pendingRemoteSurfaceTTYName: String? - var pendingRemoteSurfaceTTYSurfaceId: UUID? - var pendingRemoteSurfacePortKickReason: WorkspaceRemoteSessionController.PortScanKickReason? - var pendingRemoteSurfacePortKickSurfaceId: UUID? - // When the last live remote terminal is detached out, the source workspace may be - // closed immediately after the move succeeds. That teardown must not shut down the - // shared SSH control master that is still serving the moved terminal. - var skipControlMasterCleanupAfterDetachedRemoteTransfer = false - var transferredRemoteCleanupConfigurationsByPanelId: [UUID: WorkspaceRemoteConfiguration] = [:] #if DEBUG func debugElapsedMs(since start: TimeInterval) -> String { @@ -946,24 +831,6 @@ final class Workspace: Identifiable, ObservableObject { } } - func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { - guard let target = remoteDisplayTarget else { return nil } - return BrowserRemoteWorkspaceStatus( - target: target, - connectionState: remoteConnectionState, - heartbeatCount: remoteHeartbeatCount, - lastHeartbeatAt: remoteLastHeartbeatAt - ) - } - - func applyBrowserRemoteWorkspaceStatusToPanels() { - let snapshot = browserRemoteWorkspaceStatusSnapshot() - for panel in panels.values { - guard let browserPanel = panel as? BrowserPanel else { continue } - browserPanel.setRemoteWorkspaceStatus(snapshot) - } - } - // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -1348,22 +1215,12 @@ final class Workspace: Identifiable, ObservableObject { for (panelId, panel) in panelEntries { panelSubscriptions.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - if let cleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: panelId) { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - } TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panelId) panel.close() } - let residualCleanupConfigurations = Array(transferredRemoteCleanupConfigurationsByPanelId.values) - transferredRemoteCleanupConfigurationsByPanelId.removeAll(keepingCapacity: false) - for cleanupConfiguration in residualCleanupConfigurations { - Self.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - } - panels.removeAll(keepingCapacity: false) surfaceIdToPanelId.removeAll(keepingCapacity: false) panelSubscriptions.removeAll(keepingCapacity: false) - pendingRemoteTerminalChildExitSurfaceIds.removeAll(keepingCapacity: false) pruneSurfaceMetadata(validSurfaceIds: []) restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) @@ -1771,9 +1628,6 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } - let shouldSkipControlMasterCleanupAfterDetach = - activeRemoteTerminalSurfaceIds.contains(panelId) - && activeRemoteTerminalSurfaceIds.count == 1 #if DEBUG let detachStart = ProcessInfo.processInfo.systemUptime dlog( @@ -1800,16 +1654,7 @@ final class Workspace: Identifiable, ObservableObject { return nil } - var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) - if let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { - detached = detachedTransfer.withRemoteConfigurationIdentity(remoteConfiguration) - if shouldSkipControlMasterCleanupAfterDetach { - skipControlMasterCleanupAfterDetachedRemoteTransfer = true - if detachedTransfer.remoteCleanupConfiguration == nil { - detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) - } - } - } + let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) #if DEBUG dlog( "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -1858,13 +1703,7 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = detached.panel as? TerminalPanel { terminalPanel.updateWorkspaceId(id) } else if let browserPanel = detached.panel as? BrowserPanel { - browserPanel.reattachToWorkspace( - id, - isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, - proxyEndpoint: remoteProxyEndpoint, - remoteStatus: browserRemoteWorkspaceStatusSnapshot() - ) + browserPanel.reattachToWorkspace(id) installBrowserPanelSubscription(browserPanel) } @@ -1876,7 +1715,6 @@ final class Workspace: Identifiable, ObservableObject { } else { surfaceTTYNames.removeValue(forKey: detached.panelId) } - syncRemotePortScanTTYs() if let cachedTitle = detached.cachedTitle { panelTitles[detached.panelId] = cachedTitle } @@ -1910,7 +1748,6 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) surfaceTTYNames.removeValue(forKey: detached.panelId) - syncRemotePortScanTTYs() panelTitles.removeValue(forKey: detached.panelId) panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) @@ -1927,21 +1764,6 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = detached.panelId - let didAdoptWorkspaceRemoteTracking = - detached.remoteConfigurationIdentity != nil - && detached.remoteConfigurationIdentity == remoteConfiguration - if didAdoptWorkspaceRemoteTracking { - trackRemoteTerminalSurface(detached.panelId) - } - if let cleanupConfiguration = detached.remoteCleanupConfiguration { - if didAdoptWorkspaceRemoteTracking { - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) - } else { - transferredRemoteCleanupConfigurationsByPanelId[detached.panelId] = cleanupConfiguration - } - } else { - transferredRemoteCleanupConfigurationsByPanelId.removeValue(forKey: detached.panelId) - } if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } diff --git a/Sources/WorkspaceRemoteCLIRelayServer.swift b/Sources/WorkspaceRemoteCLIRelayServer.swift deleted file mode 100644 index 77e77d0c..00000000 --- a/Sources/WorkspaceRemoteCLIRelayServer.swift +++ /dev/null @@ -1,491 +0,0 @@ -// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the CLI relay server exposed to the remote daemon. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class WorkspaceRemoteCLIRelayServer { - private final class Session { - private enum Phase { - case awaitingAuth - case awaitingCommand - case forwarding - case closed - } - - private let connection: NWConnection - private let localSocketPath: String - private let relayID: String - private let relayToken: Data - private let queue: DispatchQueue - private let onClose: () -> Void - private let challengeProtocol = "programa-relay-auth" - private let challengeVersion = 1 - private let minimumFailureDelay: TimeInterval = 0.05 - private let maximumFrameBytes = 16 * 1024 - - private var buffer = Data() - private var phase: Phase = .awaitingAuth - private var challengeNonce = "" - private var challengeSentAt = Date() - private var isClosed = false - - init( - connection: NWConnection, - localSocketPath: String, - relayID: String, - relayToken: Data, - queue: DispatchQueue, - onClose: @escaping () -> Void - ) { - self.connection = connection - self.localSocketPath = localSocketPath - self.relayID = relayID - self.relayToken = relayToken - self.queue = queue - self.onClose = onClose - } - - func start() { - connection.stateUpdateHandler = { [weak self] state in - self?.queue.async { - self?.handleState(state) - } - } - connection.start(queue: queue) - } - - func stop() { - close() - } - - private func handleState(_ state: NWConnection.State) { - guard !isClosed else { return } - switch state { - case .ready: - sendChallenge() - receive() - case .failed, .cancelled: - close() - default: - break - } - } - - private func sendChallenge() { - challengeSentAt = Date() - challengeNonce = Self.randomHex(byteCount: 16) - let challenge: [String: Any] = [ - "protocol": challengeProtocol, - "version": challengeVersion, - "relay_id": relayID, - "nonce": challengeNonce, - ] - sendJSONLine(challenge) { _ in } - } - - private func receive() { - guard !isClosed else { return } - connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in - guard let self else { return } - self.queue.async { - if error != nil { - self.close() - return - } - if let data, !data.isEmpty { - self.buffer.append(data) - if self.buffer.count > self.maximumFrameBytes { - self.sendFailureAndClose() - return - } - self.processBufferedLines() - } - if isComplete { - self.close() - return - } - if !self.isClosed { - self.receive() - } - } - } - } - - private func processBufferedLines() { - while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { - let lineData = buffer.prefix(upTo: newlineIndex) - buffer.removeSubrange(...newlineIndex) - let line = String(data: lineData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - switch phase { - case .awaitingAuth: - handleAuthLine(line) - case .awaitingCommand: - handleCommandLine(Data(lineData) + Data([0x0A])) - case .forwarding, .closed: - return - } - } - } - - private func handleAuthLine(_ line: String) { - guard let data = line.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let receivedRelayID = object["relay_id"] as? String, - receivedRelayID == relayID, - let macHex = object["mac"] as? String, - let receivedMAC = Self.hexData(from: macHex) - else { - sendFailureAndClose() - return - } - - let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) - let expectedMAC = Self.authMAC(token: relayToken, message: message) - guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { - sendFailureAndClose() - return - } - - phase = .awaitingCommand - sendJSONLine(["ok": true]) { [weak self] _ in - self?.queue.async { - self?.processBufferedLines() - } - } - } - - private func handleCommandLine(_ commandLine: Data) { - guard !commandLine.isEmpty else { - sendFailureAndClose() - return - } - phase = .forwarding - DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in - let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } - queue.async { [weak self] in - guard let self else { return } - switch result { - case .success(let response): - self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in - self?.queue.async { - self?.close() - } - }) - case .failure: - self.sendFailureAndClose() - } - } - } - } - - private func sendFailureAndClose() { - let elapsed = Date().timeIntervalSince(challengeSentAt) - let delay = max(0, minimumFailureDelay - elapsed) - phase = .closed - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - self?.sendJSONLine(["ok": false]) { [weak self] _ in - self?.queue.async { - self?.close() - } - } - } - } - - private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { - guard !isClosed else { - completion(nil) - return - } - guard let payload = try? JSONSerialization.data(withJSONObject: object) else { - completion(nil) - return - } - connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) - } - - private func close() { - guard !isClosed else { return } - isClosed = true - phase = .closed - connection.stateUpdateHandler = nil - connection.cancel() - onClose() - } - - private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { - Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) - } - - private static func authMAC(token: Data, message: Data) -> Data { - let key = SymmetricKey(data: token) - let code = HMAC<SHA256>.authenticationCode(for: message, using: key) - return Data(code) - } - - private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { - guard lhs.count == rhs.count else { return false } - var diff: UInt8 = 0 - for index in lhs.indices { - diff |= lhs[index] ^ rhs[index] - } - return diff == 0 - } - - fileprivate static func hexData(from string: String) -> Data? { - let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } - var data = Data(capacity: normalized.count / 2) - var cursor = normalized.startIndex - while cursor < normalized.endIndex { - let next = normalized.index(cursor, offsetBy: 2) - guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { return nil } - data.append(byte) - cursor = next - } - return data - } - - private static func randomHex(byteCount: Int) -> String { - var bytes = [UInt8](repeating: 0, count: byteCount) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return bytes.map { String(format: "%02x", $0) }.joined() - } - - private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError(domain: "programa.remote.relay", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "failed to create local relay socket", - ]) - } - defer { Darwin.close(fd) } - - var timeout = timeval(tv_sec: 15, tv_usec: 0) - withUnsafePointer(to: &timeout) { pointer in - _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) - _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) - } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8CString) - guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { - throw NSError(domain: "programa.remote.relay", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "local relay socket path is too long", - ]) - } - let sunPathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 - withUnsafeMutableBytes(of: &address) { rawBuffer in - let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) - pathBytes.withUnsafeBytes { pathBuffer in - destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) - } - } - - let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) - let connectResult = withUnsafePointer(to: &address) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.connect(fd, $0, addressLength) - } - } - guard connectResult == 0 else { - throw NSError(domain: "programa.remote.relay", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "failed to connect to local cmux socket", - ]) - } - - try request.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } - var bytesRemaining = rawBuffer.count - var pointer = baseAddress - while bytesRemaining > 0 { - let written = Darwin.write(fd, pointer, bytesRemaining) - if written <= 0 { - throw NSError(domain: "programa.remote.relay", code: 4, userInfo: [ - NSLocalizedDescriptionKey: "failed to write relay request", - ]) - } - bytesRemaining -= written - pointer = pointer.advanced(by: written) - } - } - _ = shutdown(fd, SHUT_WR) - - var response = Data() - var scratch = [UInt8](repeating: 0, count: 4096) - while true { - let count = Darwin.read(fd, &scratch, scratch.count) - if count > 0 { - response.append(scratch, count: count) - continue - } - if count == 0 { - break - } - - if errno == EAGAIN || errno == EWOULDBLOCK { - if !response.isEmpty { - break - } - throw NSError(domain: "programa.remote.relay", code: 5, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for local cmux response", - ]) - } - throw NSError(domain: "programa.remote.relay", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "failed to read local cmux response", - ]) - } - return response - } - } - - private let localSocketPath: String - private let relayID: String - private let relayToken: Data - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) - - private var listener: NWListener? - private var sessions: [UUID: Session] = [:] - private var isStopped = false - private(set) var localPort: Int? - - init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { - guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { - throw NSError(domain: "programa.remote.relay", code: 7, userInfo: [ - NSLocalizedDescriptionKey: "invalid relay token", - ]) - } - self.localSocketPath = localSocketPath - self.relayID = relayID - self.relayToken = relayToken - } - - func start() throws -> Int { - if let existingPort = queue.sync(execute: { localPort }) { - return existingPort - } - - let listener = try Self.makeLoopbackListener() - let readySemaphore = DispatchSemaphore(value: 0) - let stateLock = NSLock() - var capturedError: Error? - var boundPort: Int? - - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { listenerState in - switch listenerState { - case .ready: - stateLock.lock() - boundPort = listener.port.map { Int($0.rawValue) } - stateLock.unlock() - readySemaphore.signal() - case .failed(let error): - stateLock.lock() - capturedError = error - stateLock.unlock() - readySemaphore.signal() - default: - break - } - } - listener.start(queue: queue) - - let waitResult = readySemaphore.wait(timeout: .now() + 5.0) - stateLock.lock() - let startupError = capturedError - let startupPort = boundPort - stateLock.unlock() - - if waitResult != .success { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "timed out waiting for local relay listener", - ]) - } - if let startupError { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw startupError - } - guard let startupPort, startupPort > 0 else { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - throw NSError(domain: "programa.remote.relay", code: 8, userInfo: [ - NSLocalizedDescriptionKey: "failed to bind local relay listener", - ]) - } - - return queue.sync { - if let localPort { - listener.newConnectionHandler = nil - listener.stateUpdateHandler = nil - listener.cancel() - return localPort - } - self.listener = listener - self.localPort = startupPort - return startupPort - } - } - - func stop() { - queue.sync { - guard !isStopped else { return } - isStopped = true - listener?.newConnectionHandler = nil - listener?.stateUpdateHandler = nil - listener?.cancel() - listener = nil - localPort = nil - let activeSessions = sessions.values - sessions.removeAll() - for session in activeSessions { - session.stop() - } - } - } - - private func acceptConnectionLocked(_ connection: NWConnection) { - guard !isStopped else { - connection.cancel() - return - } - let sessionID = UUID() - let session = Session( - connection: connection, - localSocketPath: localSocketPath, - relayID: relayID, - relayToken: relayToken, - queue: queue - ) { [weak self] in - self?.sessions.removeValue(forKey: sessionID) - } - sessions[sessionID] = session - session.start() - } - - private static func makeLoopbackListener() throws -> NWListener { - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.noDelay = true - let parameters = NWParameters(tls: nil, tcp: tcpOptions) - parameters.allowLocalEndpointReuse = true - parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) - return try NWListener(using: parameters) - } -} diff --git a/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift b/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift deleted file mode 100644 index 536c3eb3..00000000 --- a/Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift +++ /dev/null @@ -1,103 +0,0 @@ -// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): pending-RPC-call bookkeeping for the daemon transport. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class WorkspaceRemoteDaemonPendingCallRegistry { - final class PendingCall { - let id: Int - fileprivate let semaphore = DispatchSemaphore(value: 0) - fileprivate var response: [String: Any]? - fileprivate var failureMessage: String? - - fileprivate init(id: Int) { - self.id = id - } - } - - enum WaitOutcome { - case response([String: Any]) - case failure(String) - case missing - case timedOut - } - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") - private var nextRequestID = 1 - private var pendingCalls: [Int: PendingCall] = [:] - - func reset() { - queue.sync { - nextRequestID = 1 - pendingCalls.removeAll(keepingCapacity: false) - } - } - - func register() -> PendingCall { - queue.sync { - let call = PendingCall(id: nextRequestID) - nextRequestID += 1 - pendingCalls[call.id] = call - return call - } - } - - @discardableResult - func resolve(id: Int, payload: [String: Any]) -> Bool { - queue.sync { - guard let pendingCall = pendingCalls[id] else { return false } - pendingCall.response = payload - pendingCall.semaphore.signal() - return true - } - } - - func failAll(_ message: String) { - queue.sync { - let calls = Array(pendingCalls.values) - for call in calls { - guard call.response == nil, call.failureMessage == nil else { continue } - call.failureMessage = message - call.semaphore.signal() - } - } - } - - func remove(_ call: PendingCall) { - _ = queue.sync { - pendingCalls.removeValue(forKey: call.id) - } - } - - func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { - if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { - _ = queue.sync { - pendingCalls.removeValue(forKey: call.id) - } - // A response can win the race immediately before timeout cleanup removes the call. - // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. - _ = call.semaphore.wait(timeout: .now()) - return .timedOut - } - - return queue.sync { - guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { - return .missing - } - if let failure = pendingCall.failureMessage { - return .failure(failure) - } - guard let response = pendingCall.response else { - return .missing - } - return .response(response) - } - } -} diff --git a/Sources/WorkspaceRemoteDaemonRPCClient.swift b/Sources/WorkspaceRemoteDaemonRPCClient.swift deleted file mode 100644 index 1688ec88..00000000 --- a/Sources/WorkspaceRemoteDaemonRPCClient.swift +++ /dev/null @@ -1,485 +0,0 @@ -// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the JSON-RPC client that talks to programad-remote over ssh -T. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class WorkspaceRemoteDaemonRPCClient { - private static let maxStdoutBufferBytes = 256 * 1024 - static let requiredProxyStreamCapability = "proxy.stream.push" - - enum StreamEvent { - case data(Data) - case eof(Data) - case error(String) - } - - private struct StreamSubscription { - let queue: DispatchQueue - let handler: (StreamEvent) -> Void - } - - private let configuration: WorkspaceRemoteConfiguration - private let remotePath: String - private let onUnexpectedTermination: (String) -> Void - private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") - private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") - private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() - - private var process: Process? - private var stdinPipe: Pipe? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var stdinHandle: FileHandle? - private var stdoutHandle: FileHandle? - private var stderrHandle: FileHandle? - private var isClosed = true - private var shouldReportTermination = true - - private var stdoutBuffer = Data() - private var stderrBuffer = "" - private var streamSubscriptions: [String: StreamSubscription] = [:] - - init( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - onUnexpectedTermination: @escaping (String) -> Void - ) { - self.configuration = configuration - self.remotePath = remotePath - self.onUnexpectedTermination = onUnexpectedTermination - } - - func start() throws { - let process = Process() - let stdinPipe = Pipe() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - stateQueue.sync { - self.stdinPipe = stdinPipe - self.stdoutPipe = stdoutPipe - self.stderrPipe = stderrPipe - } - - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) - process.standardInput = stdinPipe - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - self?.stateQueue.async { - self?.consumeStdoutData(data) - } - } - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - self?.stateQueue.async { - self?.consumeStderrData(data) - } - } - process.terminationHandler = { [weak self] terminated in - self?.stateQueue.async { - self?.handleProcessTermination(terminated) - } - } - - do { - try process.run() - } catch { - throw NSError(domain: "programa.remote.daemon.rpc", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", - ]) - } - - stateQueue.sync { - self.process = process - self.stdinHandle = stdinPipe.fileHandleForWriting - self.stdoutHandle = stdoutPipe.fileHandleForReading - self.stderrHandle = stderrPipe.fileHandleForReading - self.isClosed = false - self.shouldReportTermination = true - self.stdoutBuffer = Data() - self.stderrBuffer = "" - self.streamSubscriptions.removeAll(keepingCapacity: false) - } - pendingCalls.reset() - - do { - let hello = try call(method: "hello", params: [:], timeout: 8.0) - let capabilities = (hello["capabilities"] as? [String]) ?? [] - guard capabilities.contains(Self.requiredProxyStreamCapability) else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", - ]) - } - } catch { - stop(suppressTerminationCallback: true) - throw error - } - } - - func stop() { - stop(suppressTerminationCallback: true) - } - - func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { - let result = try call( - method: "proxy.open", - params: [ - "host": host, - "port": port, - "timeout_ms": timeoutMs, - ], - timeout: 12.0 - ) - let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !streamID.isEmpty else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 3, userInfo: [ - NSLocalizedDescriptionKey: "proxy.open missing stream_id", - ]) - } - return streamID - } - - func writeStream(streamID: String, data: Data) throws { - _ = try call( - method: "proxy.write", - params: [ - "stream_id": streamID, - "data_base64": data.base64EncodedString(), - ], - timeout: 8.0 - ) - } - - func attachStream( - streamID: String, - queue: DispatchQueue, - onEvent: @escaping (StreamEvent) -> Void - ) throws { - let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStreamID.isEmpty else { - throw NSError(domain: "programa.remote.daemon.rpc", code: 17, userInfo: [ - NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", - ]) - } - - stateQueue.sync { - streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) - } - - do { - _ = try call( - method: "proxy.stream.subscribe", - params: ["stream_id": trimmedStreamID], - timeout: 8.0 - ) - } catch { - unregisterStream(streamID: trimmedStreamID) - throw error - } - } - - func unregisterStream(streamID: String) { - let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedStreamID.isEmpty else { return } - _ = stateQueue.sync { - streamSubscriptions.removeValue(forKey: trimmedStreamID) - } - } - - func closeStream(streamID: String) { - unregisterStream(streamID: streamID) - _ = try? call( - method: "proxy.close", - params: ["stream_id": streamID], - timeout: 4.0 - ) - } - - private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { - let pendingCall = pendingCalls.register() - let requestID = pendingCall.id - - let payload: Data - do { - payload = try Self.encodeJSON([ - "id": requestID, - "method": method, - "params": params, - ]) - } catch { - pendingCalls.remove(pendingCall) - throw NSError(domain: "programa.remote.daemon.rpc", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", - ]) - } - - do { - try writeQueue.sync { - try writePayload(payload) - } - } catch { - pendingCalls.remove(pendingCall) - throw error - } - - let response: [String: Any] - switch pendingCalls.wait(for: pendingCall, timeout: timeout) { - case .timedOut: - stop(suppressTerminationCallback: false) - throw NSError(domain: "programa.remote.daemon.rpc", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", - ]) - case .failure(let failure): - throw NSError(domain: "programa.remote.daemon.rpc", code: 12, userInfo: [ - NSLocalizedDescriptionKey: failure, - ]) - case .missing: - throw NSError(domain: "programa.remote.daemon.rpc", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", - ]) - case .response(let pendingResponse): - response = pendingResponse - } - - let ok = (response["ok"] as? Bool) ?? false - if ok { - return (response["result"] as? [String: Any]) ?? [:] - } - - let errorObject = (response["error"] as? [String: Any]) ?? [:] - let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" - let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" - throw NSError(domain: "programa.remote.daemon.rpc", code: 14, userInfo: [ - NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", - ]) - } - - private func writePayload(_ payload: Data) throws { - let stdinHandle: FileHandle = stateQueue.sync { - self.stdinHandle ?? FileHandle.nullDevice - } - if stdinHandle === FileHandle.nullDevice { - throw NSError(domain: "programa.remote.daemon.rpc", code: 15, userInfo: [ - NSLocalizedDescriptionKey: "daemon transport is not connected", - ]) - } - do { - try stdinHandle.write(contentsOf: payload) - try stdinHandle.write(contentsOf: Data([0x0A])) - } catch { - stop(suppressTerminationCallback: false) - throw NSError(domain: "programa.remote.daemon.rpc", code: 16, userInfo: [ - NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", - ]) - } - } - - private func consumeStdoutData(_ data: Data) { - guard !data.isEmpty else { - signalPendingFailureLocked("daemon transport closed stdout") - return - } - - stdoutBuffer.append(data) - if stdoutBuffer.count > Self.maxStdoutBufferBytes { - stdoutBuffer.removeAll(keepingCapacity: false) - signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") - process?.terminate() - return - } - while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { - var lineData = Data(stdoutBuffer[..<newlineIndex]) - stdoutBuffer.removeSubrange(...newlineIndex) - - if let carriageIndex = lineData.lastIndex(of: 0x0D), carriageIndex == lineData.index(before: lineData.endIndex) { - lineData.remove(at: carriageIndex) - } - guard !lineData.isEmpty else { continue } - - guard let payload = try? JSONSerialization.jsonObject(with: lineData, options: []) as? [String: Any] else { - continue - } - - if let responseID = Self.responseID(in: payload) { - _ = pendingCalls.resolve(id: responseID, payload: payload) - continue - } - - consumeEventPayload(payload) - } - } - - private func consumeStderrData(_ data: Data) { - guard !data.isEmpty else { return } - guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } - stderrBuffer.append(chunk) - if stderrBuffer.count > 8192 { - stderrBuffer.removeFirst(stderrBuffer.count - 8192) - } - } - - private func consumeEventPayload(_ payload: [String: Any]) { - guard let eventName = (payload["event"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !eventName.isEmpty, - let streamID = (payload["stream_id"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !streamID.isEmpty else { - return - } - - let subscription: StreamSubscription? - let event: StreamEvent? - switch eventName { - case "proxy.stream.data": - subscription = streamSubscriptions[streamID] - event = .data(Self.decodeBase64Data(payload["data_base64"])) - - case "proxy.stream.eof": - subscription = streamSubscriptions.removeValue(forKey: streamID) - event = .eof(Self.decodeBase64Data(payload["data_base64"])) - - case "proxy.stream.error": - subscription = streamSubscriptions.removeValue(forKey: streamID) - let detail = ((payload["error"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } - ?? "stream error" - event = .error(detail) - - default: - return - } - - guard let subscription, let event else { return } - subscription.queue.async { - subscription.handler(event) - } - } - - private func handleProcessTermination(_ process: Process) { - let shouldNotify: Bool = { - guard self.process === process else { return false } - return !isClosed && shouldReportTermination - }() - let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" - - isClosed = true - self.process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinHandle = nil - stdoutHandle?.readabilityHandler = nil - stdoutHandle = nil - stderrHandle?.readabilityHandler = nil - stderrHandle = nil - streamSubscriptions.removeAll(keepingCapacity: false) - signalPendingFailureLocked(detail) - - guard shouldNotify else { return } - onUnexpectedTermination(detail) - } - - private func stop(suppressTerminationCallback: Bool) { - let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { - let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" - let shouldNotify = !suppressTerminationCallback && !isClosed - shouldReportTermination = !suppressTerminationCallback - if isClosed { - return (nil, nil, nil, nil, false, detail) - } - - isClosed = true - signalPendingFailureLocked("daemon transport stopped") - let capturedProcess = process - let capturedStdin = stdinHandle - let capturedStdout = stdoutHandle - let capturedStderr = stderrHandle - - process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinHandle = nil - stdoutHandle = nil - stderrHandle = nil - streamSubscriptions.removeAll(keepingCapacity: false) - return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) - } - - captured.2?.readabilityHandler = nil - captured.3?.readabilityHandler = nil - try? captured.1?.close() - try? captured.2?.close() - try? captured.3?.close() - if let process = captured.0, process.isRunning { - process.terminate() - } - if captured.4 { - onUnexpectedTermination(captured.5) - } - } - - private func signalPendingFailureLocked(_ message: String) { - pendingCalls.failAll(message) - } - - private static func responseID(in payload: [String: Any]) -> Int? { - if let intValue = payload["id"] as? Int { - return intValue - } - if let numberValue = payload["id"] as? NSNumber { - return numberValue.intValue - } - return nil - } - - private static func decodeBase64Data(_ value: Any?) -> Data { - guard let encoded = value as? String, !encoded.isEmpty else { return Data() } - return Data(base64Encoded: encoded) ?? Data() - } - - private static func encodeJSON(_ object: [String: Any]) throws -> Data { - try JSONSerialization.data(withJSONObject: object, options: []) - } - - private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { - WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( - configuration: configuration, - remotePath: remotePath - ) - } - - private static func bestErrorLine(stderr: String) -> String? { - let lines = stderr - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - for line in lines.reversed() where !isNoiseLine(line) { - return line - } - return lines.last - } - - private static func isNoiseLine(_ line: String) -> Bool { - let lowered = line.lowercased() - if lowered.hasPrefix("warning: permanently added") { return true } - if lowered.hasPrefix("debug") { return true } - if lowered.hasPrefix("transferred:") { return true } - if lowered.hasPrefix("openbsd_") { return true } - if lowered.contains("pseudo-terminal will not be allocated") { return true } - return false - } -} diff --git a/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift b/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift deleted file mode 100644 index b6a086dd..00000000 --- a/Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift +++ /dev/null @@ -1,316 +0,0 @@ -// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): HTTP request/response rewriting for the loopback proxy alias host. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -struct WorkspaceRemoteLoopbackProxyRoute: Equatable { - let targetHost: String - let rewriteAliasHost: String? -} - -enum WorkspaceRemoteLoopbackPolicy { - static let canonicalAliasHost = "cmux-loopback.localtest.me" - - private static let legacyAliasHosts: Set<String> = [ - "programa-loopback.localtest.me", - ] - private static let acceptedAliasHosts = legacyAliasHosts.union([canonicalAliasHost]) - private static let sourceHosts: Set<String> = [ - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", - ] - - static func browserAliasURL(for url: URL) -> URL? { - guard url.scheme?.lowercased() == "http" else { return nil } - guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), - sourceHosts.contains(host) else { - return nil - } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = canonicalAliasHost - return components?.url - } - - static func displayURL(for url: URL?) -> URL? { - guard let url else { return nil } - guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), - acceptedAliasHosts.contains(host) else { - return url - } - - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.host = "localhost" - return components?.url ?? url - } - - static func proxyRoute(for host: String) -> WorkspaceRemoteLoopbackProxyRoute { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed - .trimmingCharacters(in: CharacterSet(charactersIn: ".")) - .lowercased() - guard acceptedAliasHosts.contains(normalized) else { - return WorkspaceRemoteLoopbackProxyRoute(targetHost: host, rewriteAliasHost: nil) - } - return WorkspaceRemoteLoopbackProxyRoute( - targetHost: "127.0.0.1", - rewriteAliasHost: normalized - ) - } -} - -enum RemoteLoopbackHTTPRequestRewriter { - private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) - private static let canonicalLoopbackHost = "localhost" - private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] - - static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false) - } - - static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data { - let headerData: Data - let remainder: Data - - if let headerRange = data.range(of: headerDelimiter) { - headerData = Data(data[..<headerRange.upperBound]) - remainder = Data(data[headerRange.upperBound...]) - } else if allowIncompleteHeadersAtEOF { - headerData = data - remainder = Data() - } else { - return data - } - - guard let headerText = String(data: headerData, encoding: .utf8) else { return data } - - var lines = headerText.components(separatedBy: "\r\n") - guard !lines.isEmpty else { return data } - guard let requestLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data } - guard requestLineLooksHTTP(lines[requestLineIndex]) else { return data } - - let rewrittenRequestLine = rewriteRequestLine(lines[requestLineIndex], aliasHost: aliasHost) - if rewrittenRequestLine != lines[requestLineIndex] { - lines[requestLineIndex] = rewrittenRequestLine - } - - for index in (requestLineIndex + 1)..<lines.count where !lines[index].isEmpty { - lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost) - } - - let rewrittenHeaderText = lines.joined(separator: "\r\n") - guard rewrittenHeaderText != headerText else { return data } - return Data(rewrittenHeaderText.utf8) + remainder - } - - private static func requestLineLooksHTTP(_ requestLine: String) -> Bool { - let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) - let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" - return requestLineMethods.contains(method) - } - - private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { - let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) - let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) - guard parts.count >= 3 else { return requestLine } - - var components = URLComponents(string: String(parts[1])) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return requestLine - } - components?.host = canonicalLoopbackHost - guard let rewrittenURL = components?.string else { return requestLine } - - var rewritten = parts - rewritten[1] = Substring(rewrittenURL) - let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } - let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) - return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia - } - - private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { - guard let colonIndex = line.firstIndex(of: ":") else { return line } - let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let valueStart = line.index(after: colonIndex) - let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) - - switch name { - case "host": - guard let rewrittenHost = rewriteHostValue(rawValue, aliasHost: aliasHost) else { return line } - return "\(line[..<valueStart]) \(rewrittenHost)" - case "origin", "referer": - guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line } - return "\(line[..<valueStart]) \(rewrittenURL)" - default: - return line - } - } - - private static func rewriteHostValue(_ value: String, aliasHost: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("["), - let closing = trimmed.firstIndex(of: "]") { - let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) - guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return nil - } - let remainder = String(trimmed[closing...].dropFirst()) - return canonicalLoopbackHost + remainder - } - - if let colonIndex = trimmed.lastIndex(of: ":"), !trimmed[..<colonIndex].contains(":") { - let host = String(trimmed[..<colonIndex]) - guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return nil - } - return canonicalLoopbackHost + trimmed[colonIndex...] - } - - guard BrowserInsecureHTTPSettings.normalizeHost(trimmed) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return nil - } - return canonicalLoopbackHost - } - - private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? { - var components = URLComponents(string: value) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { - return nil - } - components?.host = canonicalLoopbackHost - return components?.string - } -} - -struct RemoteLoopbackHTTPRequestStreamRewriter { - private static let maxHeaderBytes = 64 * 1024 - private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) - - private let aliasHost: String - private var pendingHeaderBytes = Data() - private var hasForwardedHeaders = false - - init(aliasHost: String) { - self.aliasHost = aliasHost - } - - mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { - guard !hasForwardedHeaders else { return data } - - pendingHeaderBytes.append(data) - if pendingHeaderBytes.count > Self.maxHeaderBytes { - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost, - allowIncompleteHeadersAtEOF: true - ) - } - - guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { - guard eof else { return Data() } - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost, - allowIncompleteHeadersAtEOF: true - ) - } - - hasForwardedHeaders = true - let payload = pendingHeaderBytes - pendingHeaderBytes = Data() - return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: payload, - aliasHost: aliasHost - ) - } -} - -enum RemoteLoopbackHTTPResponseRewriter { - private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) - private static let canonicalLoopbackHost = "localhost" - - static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { - guard let headerRange = data.range(of: headerDelimiter) else { return data } - let headerData = Data(data[..<headerRange.upperBound]) - guard let headerText = String(data: headerData, encoding: .utf8) else { return data } - - var lines = headerText.components(separatedBy: "\r\n") - guard let statusLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data } - guard lines[statusLineIndex].uppercased().hasPrefix("HTTP/") else { return data } - - for index in (statusLineIndex + 1)..<lines.count where !lines[index].isEmpty { - lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost) - } - - let rewrittenHeaderText = lines.joined(separator: "\r\n") - guard rewrittenHeaderText != headerText else { return data } - return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...] - } - - private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { - guard let colonIndex = line.firstIndex(of: ":") else { return line } - let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let valueStart = line.index(after: colonIndex) - let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) - - switch name { - case "location", "content-location", "origin", "referer", "access-control-allow-origin": - guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line } - return "\(line[..<valueStart]) \(rewrittenURL)" - case "set-cookie": - guard let rewrittenCookie = rewriteCookieValue(rawValue, aliasHost: aliasHost) else { return line } - return "\(line[..<valueStart]) \(rewrittenCookie)" - default: - return line - } - } - - private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? { - var components = URLComponents(string: value) - guard let host = components?.host, - BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { - return nil - } - components?.host = aliasHost - return components?.string - } - - private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { - let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) - guard !parts.isEmpty else { return nil } - - var didRewrite = false - let rewrittenParts = parts.map { part -> String in - let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.lowercased().hasPrefix("domain=") else { return part } - let domainValue = String(trimmed.dropFirst("domain=".count)) - guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { - return part - } - didRewrite = true - let leadingWhitespace = part.prefix { $0.isWhitespace } - return "\(leadingWhitespace)Domain=\(aliasHost)" - } - - return didRewrite ? rewrittenParts.joined(separator: ";") : nil - } -} diff --git a/Sources/WorkspaceRemoteModels.swift b/Sources/WorkspaceRemoteModels.swift deleted file mode 100644 index 2d9c4fa0..00000000 --- a/Sources/WorkspaceRemoteModels.swift +++ /dev/null @@ -1,171 +0,0 @@ -// Extracted from Workspace.swift (nuclear-review #98): remote-connection/daemon value types. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -enum RemoteDropUploadError: LocalizedError { - case unavailable - case invalidFileURL - case uploadFailed(String) - - var errorDescription: String? { - switch self { - case .unavailable: - String( - localized: "error.remoteDrop.unavailable", - defaultValue: "Remote drop is unavailable." - ) - case .invalidFileURL: - String( - localized: "error.remoteDrop.invalidFileURL", - defaultValue: "Dropped item is not a file URL." - ) - case .uploadFailed(let detail): - String.localizedStringWithFormat( - String( - localized: "error.remoteDrop.uploadFailed", - defaultValue: "Failed to upload dropped file: %@" - ), - detail - ) - } - } -} - -struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { - struct Entry: Decodable, Equatable { - let goOS: String - let goArch: String - let assetName: String - let downloadURL: String - let sha256: String - } - - let schemaVersion: Int - let appVersion: String - let releaseTag: String - let releaseURL: String - let checksumsAssetName: String - let checksumsURL: String - let entries: [Entry] - - func entry(goOS: String, goArch: String) -> Entry? { - entries.first { $0.goOS == goOS && $0.goArch == goArch } - } -} - -enum WorkspaceRemoteConnectionState: String { - case disconnected - case connecting - case connected - case error -} - -enum WorkspaceRemoteDaemonState: String { - case unavailable - case bootstrapping - case ready - case error -} - -struct WorkspaceRemoteDaemonStatus: Equatable { - var state: WorkspaceRemoteDaemonState = .unavailable - var detail: String? - var version: String? - var name: String? - var capabilities: [String] = [] - var remotePath: String? - - func payload() -> [String: Any] { - [ - "state": state.rawValue, - "detail": detail ?? NSNull(), - "version": version ?? NSNull(), - "name": name ?? NSNull(), - "capabilities": capabilities, - "remote_path": remotePath ?? NSNull(), - ] - } -} - -struct WorkspaceRemoteConfiguration: Equatable { - let destination: String - let port: Int? - let identityFile: String? - let sshOptions: [String] - let localProxyPort: Int? - let relayPort: Int? - let relayID: String? - let relayToken: String? - let localSocketPath: String? - let terminalStartupCommand: String? - let foregroundAuthToken: String? - - init( - destination: String, - port: Int?, - identityFile: String?, - sshOptions: [String], - localProxyPort: Int?, - relayPort: Int?, - relayID: String?, - relayToken: String?, - localSocketPath: String?, - terminalStartupCommand: String?, - foregroundAuthToken: String? = nil - ) { - self.destination = destination - self.port = port - self.identityFile = identityFile - self.sshOptions = sshOptions - self.localProxyPort = localProxyPort - self.relayPort = relayPort - self.relayID = relayID - self.relayToken = relayToken - self.localSocketPath = localSocketPath - self.terminalStartupCommand = terminalStartupCommand - self.foregroundAuthToken = foregroundAuthToken - } - - var displayTarget: String { - guard let port else { return destination } - return "\(destination):\(port)" - } - - var proxyBrokerTransportKey: String { - let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedPort = port.map(String.init) ?? "" - let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" - let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") - return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] - .joined(separator: "\u{1e}") - } - - private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { - options.compactMap { option in - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - }.filter { option in - proxyBrokerSSHOptionKey(option) != "controlpath" - } - } - - private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - return trimmed - .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) - .first - .map(String.init)? - .lowercased() - } -} diff --git a/Sources/WorkspaceRemoteProxyBroker.swift b/Sources/WorkspaceRemoteProxyBroker.swift deleted file mode 100644 index 786c4a0f..00000000 --- a/Sources/WorkspaceRemoteProxyBroker.swift +++ /dev/null @@ -1,923 +0,0 @@ -// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): the local<->remote proxy tunnel and its broker. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -/// Resolves the host contract used by the live proxy session. This is intentionally -/// factored out as a small runtime seam so browser URL rewriting and proxy routing -/// can be regression-tested together. -func workspaceRemoteLoopbackProxyRoute(for host: String) -> WorkspaceRemoteLoopbackProxyRoute { - WorkspaceRemoteLoopbackPolicy.proxyRoute(for: host) -} - -/// Chooses the executor used by each accepted proxy connection. Keeping this decision -/// in the live path makes cross-session scheduling behavior directly testable. -final class WorkspaceRemoteProxySessionQueueProvider { - private let queueLabelPrefix: String - private var queuesBySessionID: [UUID: DispatchQueue] = [:] - - init(tunnelQueue: DispatchQueue) { - self.queueLabelPrefix = "\(tunnelQueue.label).session" - } - - func queue(for sessionID: UUID) -> DispatchQueue { - if let existing = queuesBySessionID[sessionID] { - return existing - } - let queue = DispatchQueue( - label: "\(queueLabelPrefix).\(sessionID.uuidString)", - qos: .utility - ) - queuesBySessionID[sessionID] = queue - return queue - } - - func removeQueue(for sessionID: UUID) { - queuesBySessionID.removeValue(forKey: sessionID) - } -} - -private final class WorkspaceRemoteDaemonProxyTunnel { - private final class ProxySession { - private static let maxHandshakeBytes = 64 * 1024 - - private enum HandshakeProtocol { - case undecided - case socks5 - case connect - } - - private enum SocksStage { - case greeting - case request - } - - private struct SocksRequest { - let host: String - let port: Int - let command: UInt8 - let consumedBytes: Int - } - - let id: UUID - - private let connection: NWConnection - private let rpcClient: WorkspaceRemoteDaemonRPCClient - private let queue: DispatchQueue - private let onClose: (UUID) -> Void - - private var isClosed = false - private var protocolKind: HandshakeProtocol = .undecided - private var socksStage: SocksStage = .greeting - private var handshakeBuffer = Data() - private var streamID: String? - private var localInputEOF = false - private var rewritesLoopbackHTTPHeaders = false - private var loopbackRewriteAliasHost: String? - private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? - private var pendingRemoteHTTPHeaderBytes = Data() - private var hasForwardedRemoteHTTPHeaders = false - - init( - id: UUID, - connection: NWConnection, - rpcClient: WorkspaceRemoteDaemonRPCClient, - queue: DispatchQueue, - onClose: @escaping (UUID) -> Void - ) { - self.id = id - self.connection = connection - self.rpcClient = rpcClient - self.queue = queue - self.onClose = onClose - } - - func start() { - queue.async { [weak self] in - self?.startOnSessionQueue() - } - } - - private func startOnSessionQueue() { - guard !isClosed else { return } - connection.stateUpdateHandler = { [weak self] state in - guard let self else { return } - switch state { - case .failed(let error): - self.close(reason: "proxy client connection failed: \(error)") - case .cancelled: - self.close(reason: nil) - default: - break - } - } - connection.start(queue: queue) - receiveNext() - } - - func stop() { - queue.async { [weak self] in - self?.close(reason: nil) - } - } - - private func receiveNext() { - guard !isClosed else { return } - connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in - guard let self, !self.isClosed else { return } - - if let data, !data.isEmpty { - if self.streamID == nil { - if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { - self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") - return - } - self.handshakeBuffer.append(data) - self.processHandshakeBuffer() - } else { - self.forwardToRemote(data, eof: isComplete) - } - } - - if isComplete { - // Treat local EOF as a half-close: keep remote read loop alive so we can - // drain upstream response bytes (for example curl closing write-side after - // sending an HTTP request through SOCKS/CONNECT). - self.localInputEOF = true - if self.streamID != nil, data?.isEmpty ?? true { - self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) - } - if self.streamID == nil { - self.close(reason: nil) - } - return - } - if let error { - self.close(reason: "proxy client receive error: \(error)") - return - } - - self.receiveNext() - } - } - - private func processHandshakeBuffer() { - guard !isClosed else { return } - while streamID == nil { - switch protocolKind { - case .undecided: - guard let first = handshakeBuffer.first else { return } - protocolKind = (first == 0x05) ? .socks5 : .connect - case .socks5: - if !processSocksHandshakeStep() { - return - } - case .connect: - if !processConnectHandshakeStep() { - return - } - } - } - } - - private func processSocksHandshakeStep() -> Bool { - switch socksStage { - case .greeting: - guard handshakeBuffer.count >= 2 else { return false } - let methodCount = Int(handshakeBuffer[1]) - let total = 2 + methodCount - guard handshakeBuffer.count >= total else { return false } - - let methods = [UInt8](handshakeBuffer[2..<total]) - handshakeBuffer = Data(handshakeBuffer.dropFirst(total)) - socksStage = .request - - if !methods.contains(0x00) { - sendAndClose(Data([0x05, 0xFF])) - return false - } - sendLocal(Data([0x05, 0x00])) - return true - - case .request: - let request: SocksRequest - do { - guard let parsed = try parseSocksRequest(from: handshakeBuffer) else { return false } - request = parsed - } catch { - sendAndClose(Data([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) - return false - } - - let pending = handshakeBuffer.count > request.consumedBytes - ? Data(handshakeBuffer[request.consumedBytes...]) - : Data() - handshakeBuffer = Data() - guard request.command == 0x01 else { - sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) - return false - } - - openRemoteStream( - host: request.host, - port: request.port, - successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), - failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), - pendingPayload: pending - ) - return false - } - } - - private func parseSocksRequest(from data: Data) throws -> SocksRequest? { - let bytes = [UInt8](data) - guard bytes.count >= 4 else { return nil } - guard bytes[0] == 0x05 else { - throw NSError(domain: "programa.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) - } - - let command = bytes[1] - let addressType = bytes[3] - var cursor = 4 - let host: String - - switch addressType { - case 0x01: - guard bytes.count >= cursor + 4 + 2 else { return nil } - let octets = bytes[cursor..<(cursor + 4)].map { String($0) } - host = octets.joined(separator: ".") - cursor += 4 - - case 0x03: - guard bytes.count >= cursor + 1 else { return nil } - let length = Int(bytes[cursor]) - cursor += 1 - guard bytes.count >= cursor + length + 2 else { return nil } - let hostData = Data(bytes[cursor..<(cursor + length)]) - host = String(data: hostData, encoding: .utf8) ?? "" - cursor += length - - case 0x04: - guard bytes.count >= cursor + 16 + 2 else { return nil } - var address = in6_addr() - withUnsafeMutableBytes(of: &address) { target in - for i in 0..<16 { - target[i] = bytes[cursor + i] - } - } - var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - let pointer = withUnsafePointer(to: &address) { - inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) - } - host = pointer != nil ? String(cString: text) : "" - cursor += 16 - - default: - throw NSError(domain: "programa.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) - } - - guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw NSError(domain: "programa.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) - } - guard bytes.count >= cursor + 2 else { return nil } - let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) - cursor += 2 - - guard port > 0 && port <= 65535 else { - throw NSError(domain: "programa.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) - } - - return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) - } - - private func processConnectHandshakeStep() -> Bool { - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard let headerRange = handshakeBuffer.range(of: marker) else { return false } - - let headerData = Data(handshakeBuffer[..<headerRange.upperBound]) - let pending = headerRange.upperBound < handshakeBuffer.count - ? Data(handshakeBuffer[headerRange.upperBound...]) - : Data() - handshakeBuffer = Data() - guard let headerText = String(data: headerData, encoding: .utf8) else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - let firstLine = headerText.components(separatedBy: "\r\n").first ?? "" - let parts = firstLine.split(whereSeparator: \.isWhitespace).map(String.init) - guard parts.count >= 2, parts[0].uppercased() == "CONNECT" else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { - sendAndClose(Self.httpResponse(status: "400 Bad Request")) - return false - } - - openRemoteStream( - host: host, - port: port, - successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), - failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), - pendingPayload: pending - ) - return false - } - - private func openRemoteStream( - host: String, - port: Int, - successResponse: Data, - failureResponse: Data, - pendingPayload: Data - ) { - guard !isClosed else { return } - do { - let route = workspaceRemoteLoopbackProxyRoute(for: host) - rewritesLoopbackHTTPHeaders = route.rewriteAliasHost != nil - loopbackRewriteAliasHost = route.rewriteAliasHost - if let rewriteAliasHost = route.rewriteAliasHost { - loopbackRequestHeaderRewriter = RemoteLoopbackHTTPRequestStreamRewriter( - aliasHost: rewriteAliasHost - ) - } else { - loopbackRequestHeaderRewriter = nil - } - pendingRemoteHTTPHeaderBytes = Data() - hasForwardedRemoteHTTPHeaders = false - let streamID = try rpcClient.openStream(host: route.targetHost, port: port) - self.streamID = streamID - try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in - self?.handleRemoteStreamEvent(streamID: streamID, event: event) - } - connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - return - } - if !pendingPayload.isEmpty { - self.forwardToRemote(pendingPayload, allowAfterEOF: true) - } - }) - } catch { - sendAndClose(failureResponse) - } - } - - private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { - guard !isClosed else { return } - guard !localInputEOF || allowAfterEOF else { return } - guard let streamID else { return } - do { - let outgoingData: Data - if rewritesLoopbackHTTPHeaders { - outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data - } else { - outgoingData = data - } - guard !outgoingData.isEmpty else { return } - try rpcClient.writeStream(streamID: streamID, data: outgoingData) - } catch { - close(reason: "proxy.write failed: \(error.localizedDescription)") - } - } - - private func handleRemoteStreamEvent( - streamID: String, - event: WorkspaceRemoteDaemonRPCClient.StreamEvent - ) { - guard !isClosed else { return } - guard self.streamID == streamID else { return } - - switch event { - case .data(let data): - forwardRemotePayloadToLocal(data, eof: false) - - case .eof(let data): - forwardRemotePayloadToLocal(data, eof: true) - - case .error(let detail): - close(reason: "proxy.stream failed: \(detail)") - } - } - - private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { - let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) - if !localData.isEmpty { - connection.send(content: localData, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - return - } - if eof { - self.close(reason: nil) - } - }) - return - } - - if eof { - close(reason: nil) - } - } - - private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { - guard rewritesLoopbackHTTPHeaders else { return data } - guard !data.isEmpty else { return data } - guard !hasForwardedRemoteHTTPHeaders else { return data } - - pendingRemoteHTTPHeaderBytes.append(data) - let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) - guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { - guard eof else { return Data() } - hasForwardedRemoteHTTPHeaders = true - let payload = pendingRemoteHTTPHeaderBytes - pendingRemoteHTTPHeaderBytes = Data() - return payload - } - - hasForwardedRemoteHTTPHeaders = true - let payload = pendingRemoteHTTPHeaderBytes - pendingRemoteHTTPHeaderBytes = Data() - guard let rewriteAliasHost = loopbackRewriteAliasHost else { - return payload - } - return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( - data: payload, - aliasHost: rewriteAliasHost - ) - } - - private func close(reason: String?) { - guard !isClosed else { return } - isClosed = true - - let streamID = self.streamID - self.streamID = nil - - if let streamID { - rpcClient.closeStream(streamID: streamID) - } - connection.cancel() - onClose(id) - } - - private func sendLocal(_ data: Data) { - guard !isClosed else { return } - connection.send(content: data, completion: .contentProcessed { [weak self] error in - guard let self else { return } - if let error { - self.close(reason: "proxy client send error: \(error)") - } - }) - } - - private func sendAndClose(_ data: Data) { - guard !isClosed else { return } - connection.send(content: data, completion: .contentProcessed { [weak self] _ in - self?.close(reason: nil) - }) - } - - private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { - let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if trimmed.hasPrefix("[") { - guard let closing = trimmed.firstIndex(of: "]") else { return nil } - let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) - let portStart = trimmed.index(after: closing) - guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil } - let portString = String(trimmed[trimmed.index(after: portStart)...]) - guard let port = Int(portString), port > 0, port <= 65535 else { return nil } - return (host, port) - } - - guard let colon = trimmed.lastIndex(of: ":") else { return nil } - let host = String(trimmed[..<colon]) - let portString = String(trimmed[trimmed.index(after: colon)...]) - guard !host.isEmpty else { return nil } - guard let port = Int(portString), port > 0, port <= 65535 else { return nil } - return (host, port) - } - - private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { - var text = "HTTP/1.1 \(status)\r\nProxy-Agent: Programa\r\n" - if closeAfterResponse { - text += "Connection: close\r\n" - } - text += "\r\n" - return Data(text.utf8) - } - } - - private let configuration: WorkspaceRemoteConfiguration - private let remotePath: String - private let localPort: Int - private let onFatalError: (String) -> Void - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) - private lazy var sessionQueueProvider = WorkspaceRemoteProxySessionQueueProvider(tunnelQueue: queue) - - private var listener: NWListener? - private var rpcClient: WorkspaceRemoteDaemonRPCClient? - private var sessions: [UUID: ProxySession] = [:] - private var isStopped = false - - init( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - localPort: Int, - onFatalError: @escaping (String) -> Void - ) { - self.configuration = configuration - self.remotePath = remotePath - self.localPort = localPort - self.onFatalError = onFatalError - } - - func start() throws { - var capturedError: Error? - queue.sync { - guard !isStopped else { - capturedError = NSError(domain: "programa.remote.proxy", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "proxy tunnel already stopped", - ]) - return - } - do { - let client = WorkspaceRemoteDaemonRPCClient( - configuration: configuration, - remotePath: remotePath - ) { [weak self] detail in - self?.queue.async { - self?.failLocked("Remote daemon transport failed: \(detail)") - } - } - try client.start() - - let listener = try Self.makeLoopbackListener(port: localPort) - listener.newConnectionHandler = { [weak self] connection in - self?.queue.async { - self?.acceptConnectionLocked(connection) - } - } - listener.stateUpdateHandler = { [weak self] state in - self?.queue.async { - self?.handleListenerStateLocked(state) - } - } - - self.rpcClient = client - self.listener = listener - listener.start(queue: queue) - } catch { - capturedError = error - stopLocked(notify: false) - } - } - if let capturedError { - throw capturedError - } - } - - func stop() { - queue.sync { - stopLocked(notify: false) - } - } - - private func handleListenerStateLocked(_ state: NWListener.State) { - guard !isStopped else { return } - switch state { - case .failed(let error): - failLocked("Local proxy listener failed: \(error)") - default: - break - } - } - - private func acceptConnectionLocked(_ connection: NWConnection) { - guard !isStopped else { - connection.cancel() - return - } - guard let rpcClient else { - connection.cancel() - return - } - - let sessionID = UUID() - let session = ProxySession( - id: sessionID, - connection: connection, - rpcClient: rpcClient, - queue: sessionQueueProvider.queue(for: sessionID) - ) { [weak self] id in - self?.queue.async { - self?.sessions.removeValue(forKey: id) - self?.sessionQueueProvider.removeQueue(for: id) - } - } - sessions[session.id] = session - session.start() - } - - private func failLocked(_ detail: String) { - guard !isStopped else { return } - stopLocked(notify: false) - onFatalError(detail) - } - - private func stopLocked(notify: Bool) { - guard !isStopped else { return } - isStopped = true - - listener?.stateUpdateHandler = nil - listener?.newConnectionHandler = nil - listener?.cancel() - listener = nil - - let activeSessions = sessions.values - sessions.removeAll() - for session in activeSessions { - session.stop() - } - - rpcClient?.stop() - rpcClient = nil - } - - private static func makeLoopbackListener(port: Int) throws -> NWListener { - guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { - throw NSError(domain: "programa.remote.proxy", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "invalid local proxy port \(port)", - ]) - } - let tcpOptions = NWProtocolTCP.Options() - tcpOptions.noDelay = true - let parameters = NWParameters(tls: nil, tcp: tcpOptions) - parameters.allowLocalEndpointReuse = true - parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) - return try NWListener(using: parameters) - } -} - -final class WorkspaceRemoteProxyBroker { - enum Update { - case connecting - case ready(BrowserProxyEndpoint) - case error(String) - } - - final class Lease { - private let key: String - private let subscriberID: UUID - private weak var broker: WorkspaceRemoteProxyBroker? - private var isReleased = false - - fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { - self.key = key - self.subscriberID = subscriberID - self.broker = broker - } - - func release() { - guard !isReleased else { return } - isReleased = true - broker?.release(key: key, subscriberID: subscriberID) - } - - deinit { - release() - } - } - - private final class Entry { - let configuration: WorkspaceRemoteConfiguration - var remotePath: String - var tunnel: WorkspaceRemoteDaemonProxyTunnel? - var endpoint: BrowserProxyEndpoint? - var restartWorkItem: DispatchWorkItem? - var restartRetryCount = 0 - var subscribers: [UUID: (Update) -> Void] = [:] - - init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { - self.configuration = configuration - self.remotePath = remotePath - } - } - - static let shared = WorkspaceRemoteProxyBroker() - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) - private var entries: [String: Entry] = [:] - - func acquire( - configuration: WorkspaceRemoteConfiguration, - remotePath: String, - onUpdate: @escaping (Update) -> Void - ) -> Lease { - queue.sync { - let key = Self.transportKey(for: configuration) - let subscriberID = UUID() - let entry: Entry - if let existing = entries[key] { - entry = existing - if existing.remotePath != remotePath { - existing.remotePath = remotePath - existing.restartRetryCount = 0 - if existing.tunnel != nil { - stopEntryRuntimeLocked(existing) - notifyLocked(existing, update: .connecting) - } - } - } else { - entry = Entry(configuration: configuration, remotePath: remotePath) - entries[key] = entry - } - - entry.subscribers[subscriberID] = onUpdate - if let endpoint = entry.endpoint { - onUpdate(.ready(endpoint)) - } else { - onUpdate(.connecting) - } - - if entry.tunnel == nil, entry.restartWorkItem == nil { - startEntryLocked(key: key, entry: entry) - } - - return Lease(key: key, subscriberID: subscriberID, broker: self) - } - } - - private func release(key: String, subscriberID: UUID) { - queue.async { [weak self] in - guard let self, let entry = self.entries[key] else { return } - entry.subscribers.removeValue(forKey: subscriberID) - guard entry.subscribers.isEmpty else { return } - self.teardownEntryLocked(key: key, entry: entry) - } - } - - private func startEntryLocked(key: String, entry: Entry) { - entry.restartWorkItem?.cancel() - entry.restartWorkItem = nil - - let localPort: Int - if let forcedLocalPort = entry.configuration.localProxyPort { - // Internal deterministic test hook used by docker regressions to force bind conflicts. - localPort = forcedLocalPort - } else { - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - guard let allocatedPort = Self.allocateLoopbackPort() else { - notifyLocked( - entry, - update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: retryDelay))") - ) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - return - } - localPort = allocatedPort - } - - do { - let tunnel = WorkspaceRemoteDaemonProxyTunnel( - configuration: entry.configuration, - remotePath: entry.remotePath, - localPort: localPort - ) { [weak self] detail in - self?.queue.async { - self?.handleTunnelFailureLocked(key: key, detail: detail) - } - } - try tunnel.start() - entry.tunnel = tunnel - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) - entry.endpoint = endpoint - entry.restartRetryCount = 0 - notifyLocked(entry, update: .ready(endpoint)) - } catch { - stopEntryRuntimeLocked(entry) - let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - } - } - - private func handleTunnelFailureLocked(key: String, detail: String) { - guard let entry = entries[key], entry.tunnel != nil else { return } - stopEntryRuntimeLocked(entry) - let retryDelay = Self.retryDelay(baseDelay: 3.0, retry: entry.restartRetryCount + 1) - notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: retryDelay))")) - scheduleRestartLocked(key: key, entry: entry, baseDelay: 3.0) - } - - private func scheduleRestartLocked(key: String, entry: Entry, baseDelay: TimeInterval) { - guard !entry.subscribers.isEmpty else { - teardownEntryLocked(key: key, entry: entry) - return - } - guard entry.restartWorkItem == nil else { return } - entry.restartRetryCount += 1 - let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: entry.restartRetryCount) - - let workItem = DispatchWorkItem { [weak self] in - guard let self, let currentEntry = self.entries[key] else { return } - currentEntry.restartWorkItem = nil - guard !currentEntry.subscribers.isEmpty else { - self.teardownEntryLocked(key: key, entry: currentEntry) - return - } - self.notifyLocked(currentEntry, update: .connecting) - self.startEntryLocked(key: key, entry: currentEntry) - } - - entry.restartWorkItem = workItem - queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) - } - - private func teardownEntryLocked(key: String, entry: Entry) { - entry.restartWorkItem?.cancel() - entry.restartWorkItem = nil - stopEntryRuntimeLocked(entry) - entries.removeValue(forKey: key) - } - - private func stopEntryRuntimeLocked(_ entry: Entry) { - entry.tunnel?.stop() - entry.tunnel = nil - entry.endpoint = nil - } - - private func notifyLocked(_ entry: Entry, update: Update) { - for callback in entry.subscribers.values { - callback(update) - } - } - - private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { - configuration.proxyBrokerTransportKey - } - - private static func allocateLoopbackPort() -> Int? { - for _ in 0..<8 { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return nil } - defer { close(fd) } - - var yes: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size)) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(0) - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) - } - } - guard bindResult == 0 else { continue } - - var bound = sockaddr_in() - var len = socklen_t(MemoryLayout<sockaddr_in>.size) - let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - getsockname(fd, sockaddrPtr, &len) - } - } - guard nameResult == 0 else { continue } - - let port = Int(UInt16(bigEndian: bound.sin_port)) - if port > 0 && port <= 65535 { - return port - } - } - return nil - } - - private static func retrySuffix(delay: TimeInterval) -> String { - let seconds = max(1, Int(delay.rounded())) - return " (retry in \(seconds)s)" - } - - private static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { - let exponent = Double(max(0, retry - 1)) - return min(baseDelay * pow(2.0, exponent), 60.0) - } -} diff --git a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift b/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift deleted file mode 100644 index 4f9ffa10..00000000 --- a/Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Extracted from WorkspaceRemoteDaemon.swift (nuclear-review #98): SSH argument builders for daemon-transport batch commands. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -enum WorkspaceRemoteSSHBatchCommandBuilder { - static func daemonTransportArguments( - configuration: WorkspaceRemoteConfiguration, - remotePath: String - ) -> [String] { - let script = "exec \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - return ["-T"] - + batchArguments(configuration: configuration) - + ["-o", "RequestTTY=no", configuration.destination, command] - } - - static func reverseRelayControlMasterArguments( - configuration: WorkspaceRemoteConfiguration, - controlCommand: String, - forwardSpec: String - ) -> [String]? { - guard let controlPath = RemoteSSHConnectionPolicy.optionValue(named: "ControlPath", in: configuration.sshOptions)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !controlPath.isEmpty, - controlPath.lowercased() != "none" else { - return nil - } - - var args = batchArguments(configuration: configuration) - args += ["-O", controlCommand, "-R", forwardSpec, configuration.destination] - return args - } - - /// Builds the `scp` argument list for staging a local file to `configuration.destination`. - /// Shared by the `programad-remote` binary upload and the dropped-file upload so the - /// IPv6-bracketing fix (see `RemoteSSHConnectionPolicy.scpRemoteDestination`) only has - /// to be applied once. - static func scpUploadArguments( - configuration: WorkspaceRemoteConfiguration, - localPath: String, - remotePath: String - ) -> [String] { - let scpSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - var args: [String] = ["-q", "-o", "ControlMaster=no"] - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: scpSSHOptions) - if let port = configuration.port { - args += ["-P", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in scpSSHOptions { - args += ["-o", option] - } - args += [localPath, "\(RemoteSSHConnectionPolicy.scpRemoteDestination(configuration.destination)):\(remotePath)"] - return args - } - - private static func batchArguments(configuration: WorkspaceRemoteConfiguration) -> [String] { - let effectiveSSHOptions = RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - var args = RemoteSSHConnectionPolicy.keepaliveArguments - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) - // Batch helpers may reuse an existing ControlPath, but must not negotiate a new master. - args += RemoteSSHConnectionPolicy.batchModeArguments - if let port = configuration.port { - args += ["-p", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in effectiveSSHOptions { - args += ["-o", option] - } - return args - } -} diff --git a/Sources/WorkspaceRemoteSession.swift b/Sources/WorkspaceRemoteSession.swift deleted file mode 100644 index 7fc47a62..00000000 --- a/Sources/WorkspaceRemoteSession.swift +++ /dev/null @@ -1,215 +0,0 @@ -// Extracted from Workspace.swift (nuclear-review N5): the remote-session -// infrastructure below is self-contained — its only edge back to Workspace -// is the weak reference held by WorkspaceRemoteSessionController. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class WorkspaceRemoteSessionController { - enum PortScanKickReason: String { - case command - case refresh - - var burstOffsets: [Double] { - switch self { - case .command: - return [0.5, 1.5, 3.0, 5.0, 7.5, 10.0] - case .refresh: - return [0.0] - } - } - - func merged(with other: Self) -> Self { - switch (self, other) { - case (.command, _), (_, .command): - return .command - case (.refresh, .refresh): - return .refresh - } - } - } - - struct RetrySchedule { - let retry: Int - let delay: TimeInterval - } - - struct CommandResult { - let status: Int32 - let stdout: String - let stderr: String - } - - struct RemotePlatform { - let goOS: String - let goArch: String - } - - struct RemoteBootstrapState { - let platform: RemotePlatform - let binaryExists: Bool - } - - struct DaemonHello { - let name: String - let version: String - let capabilities: [String] - let remotePath: String - } - - let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) - let queueKey = DispatchSpecificKey<Void>() - weak var workspace: Workspace? - let configuration: WorkspaceRemoteConfiguration - let controllerID: UUID - - enum RemotePortPollingMode { - case hostWide - case hostWideDelta - case ttyScoped - - var initialDelay: TimeInterval { - switch self { - case .hostWide: - return 0.5 - case .hostWideDelta: - return 0.5 - case .ttyScoped: - return 1.0 - } - } - - var repeatInterval: TimeInterval { - switch self { - case .hostWide: - return 2.0 - case .hostWideDelta: - return 5.0 - case .ttyScoped: - return 5.0 - } - } - } - - var isStopping = false - var proxyLease: WorkspaceRemoteProxyBroker.Lease? - var proxyEndpoint: BrowserProxyEndpoint? - var daemonReady = false - var daemonBootstrapVersion: String? - var daemonRemotePath: String? - var reverseRelayProcess: Process? - var reverseRelayControlMasterForwardSpec: String? - var cliRelayServer: WorkspaceRemoteCLIRelayServer? - var remotePortScanTTYNames: [UUID: String] = [:] - var remoteScannedPortsByPanel: [UUID: [Int]] = [:] - var remotePortScanBurstActive = false - var remotePortScanActiveReason: PortScanKickReason? - var remotePortScanPendingReason: PortScanKickReason? - var remotePortScanGeneration: UInt64 = 0 - var remotePortScanCoalesceWorkItem: DispatchWorkItem? - var remotePortPollTimer: DispatchSourceTimer? - var remotePortPollMode: RemotePortPollingMode? - var polledRemotePorts: [Int] = [] - var remotePortPollBaselinePorts: Set<Int>? - var keepPolledRemotePortsUntilTTYScan = false - var bootstrapRemoteTTYResolved = false - var bootstrapRemoteTTYRetryWorkItem: DispatchWorkItem? - var bootstrapRemoteTTYFetchInFlight = false - var bootstrapRemoteTTYRetryCount = 0 - var reverseRelayStderrPipe: Pipe? - var reverseRelayRestartWorkItem: DispatchWorkItem? - var reverseRelayStderrBuffer = "" - var reverseRelayGeneration: UInt64 = 0 - var reconnectRetryCount = 0 - var reconnectWorkItem: DispatchWorkItem? - var heartbeatCount: Int = 0 - var connectionAttemptStartedAt: Date? - - static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 - - init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { - self.workspace = workspace - self.configuration = configuration - self.controllerID = controllerID - queue.setSpecific(key: queueKey, value: ()) - } - - func start() { - debugLog("remote.session.start \(debugConfigSummary())") - queue.async { [weak self] in - guard let self else { return } - guard !self.isStopping else { return } - self.beginConnectionAttemptLocked() - } - } - - func stop() { - if DispatchQueue.getSpecific(key: queueKey) != nil { - stopAllLocked() - return - } - queue.async { [self] in - stopAllLocked() - } - } - - func uploadDroppedFiles( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation, - completion: @escaping (Result<[String], Error>) -> Void - ) { - queue.async { [weak self] in - guard let self else { - DispatchQueue.main.async { - completion(.failure(RemoteDropUploadError.unavailable)) - } - return - } - - do { - try operation.throwIfCancelled() - let remotePaths = try self.uploadDroppedFilesLocked(fileURLs, operation: operation) - try operation.throwIfCancelled() - DispatchQueue.main.async { [weak self] in - if operation.isCancelled { - guard let self else { - completion(.failure(TerminalImageTransferExecutionError.cancelled)) - return - } - self.queue.async { [weak self] in - self?.cleanupUploadedRemotePaths(remotePaths) - DispatchQueue.main.async { - completion(.failure(TerminalImageTransferExecutionError.cancelled)) - } - } - } else { - completion(.success(remotePaths)) - } - } - } catch { - DispatchQueue.main.async { - completion(.failure(error)) - } - } - } - } - - func uploadDroppedFiles( - _ fileURLs: [URL], - completion: @escaping (Result<[String], Error>) -> Void - ) { - uploadDroppedFiles( - fileURLs, - operation: TerminalImageTransferOperation(), - completion: completion - ) - } - -} diff --git a/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift b/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift deleted file mode 100644 index b2de15a1..00000000 --- a/Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift +++ /dev/null @@ -1,636 +0,0 @@ -// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): connection-attempt/reverse-relay/proxy orchestration and status publishing. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -extension WorkspaceRemoteSessionController { - func stopAllLocked() { - debugLog("remote.session.stop \(debugConfigSummary())") - isStopping = true - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - reverseRelayRestartWorkItem?.cancel() - reverseRelayRestartWorkItem = nil - remotePortScanCoalesceWorkItem?.cancel() - remotePortScanCoalesceWorkItem = nil - stopReverseRelayLocked() - remotePortScanGeneration &+= 1 - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - remotePortScanPendingReason = nil - remotePortScanTTYNames.removeAll() - remoteScannedPortsByPanel.removeAll() - stopRemotePortPollingLocked() - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - keepPolledRemotePortsUntilTTYScan = false - bootstrapRemoteTTYResolved = false - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYFetchInFlight = false - bootstrapRemoteTTYRetryCount = 0 - - proxyLease?.release() - proxyLease = nil - proxyEndpoint = nil - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - publishProxyEndpoint(nil) - publishPortsSnapshotLocked() - } - - func beginConnectionAttemptLocked() { - guard !isStopping else { return } - - Self.killOrphanedRemoteSSHProcesses( - destination: configuration.destination, - relayPort: configuration.relayPort - ) - connectionAttemptStartedAt = Date() - debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") - reconnectWorkItem = nil - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYFetchInFlight = false - if remotePortScanTTYNames.isEmpty { - bootstrapRemoteTTYResolved = false - bootstrapRemoteTTYRetryCount = 0 - } - let connectDetail: String - let bootstrapDetail: String - if reconnectRetryCount > 0 { - connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" - bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" - } else { - connectDetail = "Connecting to \(configuration.displayTarget)" - bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" - } - publishState(.connecting, detail: connectDetail) - publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) - do { - let hello = try bootstrapDaemonLocked() - guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { - throw NSError(domain: "programa.remote.daemon", code: 43, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", - ]) - } - daemonReady = true - daemonBootstrapVersion = hello.version - daemonRemotePath = hello.remotePath - publishDaemonStatus( - .ready, - detail: "Remote daemon ready", - version: hello.version, - name: hello.name, - capabilities: hello.capabilities, - remotePath: hello.remotePath - ) - recordHeartbeatActivityLocked() - startReverseRelayLocked(remotePath: hello.remotePath) - requestBootstrapRemoteTTYIfNeededLocked() - startProxyLocked() - } catch { - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - } - } - - func startProxyLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard proxyLease == nil else { return } - guard let remotePath = daemonRemotePath, - !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - let retrySchedule = scheduleReconnectLocked(baseDelay: 4.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - return - } - - let lease = WorkspaceRemoteProxyBroker.shared.acquire( - configuration: configuration, - remotePath: remotePath - ) { [weak self] update in - self?.queue.async { - self?.handleProxyBrokerUpdateLocked(update) - } - } - proxyLease = lease - } - - func startReverseRelayLocked(remotePath: String) { - guard !isStopping else { return } - guard daemonReady else { return } - guard let relayPort = configuration.relayPort, relayPort > 0, - let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), - !relayID.isEmpty, - let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), - !relayToken.isEmpty, - let localSocketPath = configuration.localSocketPath? - .trimmingCharacters(in: .whitespacesAndNewlines), - !localSocketPath.isEmpty else { - return - } - guard reverseRelayProcess == nil else { return } - guard reverseRelayControlMasterForwardSpec == nil else { return } - - reverseRelayRestartWorkItem?.cancel() - reverseRelayRestartWorkItem = nil - var relayServer: WorkspaceRemoteCLIRelayServer? - do { - let server = try ensureCLIRelayServerLocked( - localSocketPath: localSocketPath, - relayID: relayID, - relayToken: relayToken - ) - relayServer = server - let localRelayPort = try server.start() - Self.killOrphanedRemoteSSHProcesses( - destination: configuration.destination, - relayPort: relayPort - ) - let forwardSpec = "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)" - - if startReverseRelayViaControlMasterLocked(forwardSpec: forwardSpec) { - cliRelayServer = relayServer - reverseRelayStderrBuffer = "" - do { - try installRemoteRelayMetadataLocked( - remotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - } catch { - debugLog("remote.relay.metadata.error \(error.localizedDescription)") - stopReverseRelayLocked() - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - recordHeartbeatActivityLocked() - debugLog( - "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget) controlMaster=1" - ) - return - } - - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe - - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleReverseRelayTerminationLocked(process: terminated) - } - } - - try process.run() - if let startupFailure = Self.reverseRelayStartupFailureDetail( - process: process, - stderrPipe: stderrPipe - ) { - let retryDelay = 2.0 - let retrySeconds = max(1, Int(retryDelay.rounded())) - debugLog( - "remote.relay.startFailed relayPort=\(relayPort) " + - "error=\(startupFailure)" - ) - relayServer?.stop() - publishDaemonStatus( - .error, - detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" - ) - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) - return - } - reverseRelayGeneration &+= 1 - let relayGeneration = reverseRelayGeneration - reverseRelayProcess = process - cliRelayServer = relayServer - reverseRelayStderrPipe = stderrPipe - reverseRelayStderrBuffer = "" - installReverseRelayStderrHandlerLocked(stderrPipe, generation: relayGeneration) - do { - try installRemoteRelayMetadataLocked( - remotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - } catch { - debugLog("remote.relay.metadata.error \(error.localizedDescription)") - stopReverseRelayLocked() - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - return - } - recordHeartbeatActivityLocked() - debugLog( - "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + - "target=\(configuration.displayTarget) controlMaster=0" - ) - } catch { - debugLog( - "remote.relay.startFailed relayPort=\(relayPort) " + - "error=\(error.localizedDescription)" - ) - relayServer?.stop() - cliRelayServer = nil - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - } - } - - func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe, generation: UInt64) { - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - self?.appendReverseRelayStderrLocked( - data, - from: stderrPipe, - generation: generation - ) - } - } - } - - @discardableResult - func appendReverseRelayStderrLocked( - _ data: Data, - from stderrPipe: Pipe, - generation: UInt64 - ) -> Bool { - guard reverseRelayGeneration == generation, - reverseRelayStderrPipe === stderrPipe, - let chunk = String(data: data, encoding: .utf8), - !chunk.isEmpty else { - return false - } - reverseRelayStderrBuffer.append(chunk) - if reverseRelayStderrBuffer.count > 8192 { - reverseRelayStderrBuffer.removeFirst(reverseRelayStderrBuffer.count - 8192) - } - return true - } - - func handleReverseRelayTerminationLocked(process: Process) { - guard reverseRelayProcess === process else { return } - let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - reverseRelayGeneration &+= 1 - reverseRelayProcess = nil - reverseRelayStderrPipe = nil - - guard !isStopping else { return } - guard let remotePath = daemonRemotePath, - !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - - let detail = stderrDetail ?? "status=\(process.terminationStatus)" - debugLog("remote.relay.exit \(detail)") - scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) - } - - func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { - guard !isStopping else { return } - reverseRelayRestartWorkItem?.cancel() - - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reverseRelayRestartWorkItem = nil - guard !self.isStopping else { return } - guard self.reverseRelayProcess == nil else { return } - guard self.daemonReady else { return } - self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) - } - reverseRelayRestartWorkItem = workItem - queue.asyncAfter(deadline: .now() + delay, execute: workItem) - } - - func stopReverseRelayLocked() { - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - // Invalidate callbacks that already captured stderr bytes but have not reached this - // controller queue. They belong to the stopped process and must never prefix a restart. - reverseRelayGeneration &+= 1 - if let reverseRelayProcess, reverseRelayProcess.isRunning { - reverseRelayProcess.terminate() - } - reverseRelayProcess = nil - stopReverseRelayViaControlMasterLocked() - reverseRelayStderrPipe = nil - reverseRelayStderrBuffer = "" - cliRelayServer?.stop() - cliRelayServer = nil - removeRemoteRelayMetadataLocked() - } - - func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { - guard !isStopping else { return } - switch update { - case .connecting: - debugLog("remote.proxy.connecting \(debugConfigSummary())") - if proxyEndpoint == nil { - publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") - } - case .ready(let endpoint): - debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - guard proxyEndpoint != endpoint else { - recordHeartbeatActivityLocked() - return - } - proxyEndpoint = endpoint - publishProxyEndpoint(endpoint) - updateRemotePortPollingStateLocked() - publishPortsSnapshotLocked() - publishState( - .connected, - detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" - ) - requestBootstrapRemoteTTYIfNeededLocked() - recordHeartbeatActivityLocked() - case .error(let detail): - debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") - remotePortScanGeneration &+= 1 - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - remotePortScanPendingReason = nil - remotePortScanCoalesceWorkItem?.cancel() - remotePortScanCoalesceWorkItem = nil - remoteScannedPortsByPanel.removeAll() - stopRemotePortPollingLocked() - polledRemotePorts = [] - keepPolledRemotePortsUntilTTYScan = false - proxyEndpoint = nil - publishProxyEndpoint(nil) - publishPortsSnapshotLocked() - publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") - guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } - - proxyLease?.release() - proxyLease = nil - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - - let retrySchedule = scheduleReconnectLocked(baseDelay: 2.0) - let retrySuffix = Self.retrySuffix(retry: retrySchedule.retry, delay: retrySchedule.delay) - publishDaemonStatus( - .error, - detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" - ) - } - } - - @discardableResult - func scheduleReconnectLocked(baseDelay: TimeInterval) -> RetrySchedule { - let retryNumber = reconnectRetryCount + 1 - let retryDelay = Self.retryDelay(baseDelay: baseDelay, retry: retryNumber) - guard !isStopping else { return RetrySchedule(retry: retryNumber, delay: retryDelay) } - reconnectWorkItem?.cancel() - reconnectRetryCount = retryNumber - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reconnectWorkItem = nil - guard !self.isStopping else { return } - guard self.proxyLease == nil else { return } - self.beginConnectionAttemptLocked() - } - reconnectWorkItem = workItem - queue.asyncAfter(deadline: .now() + retryDelay, execute: workItem) - return RetrySchedule(retry: retryNumber, delay: retryDelay) - } - - func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteConnectionStateUpdate( - state, - detail: detail, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - func publishDaemonStatus( - _ state: WorkspaceRemoteDaemonState, - detail: String?, - version: String? = nil, - name: String? = nil, - capabilities: [String] = [], - remotePath: String? = nil - ) { - let controllerID = self.controllerID - let status = WorkspaceRemoteDaemonStatus( - state: state, - detail: detail, - version: version, - name: name, - capabilities: capabilities, - remotePath: remotePath - ) - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteDaemonStatusUpdate( - status, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteProxyEndpointUpdate(endpoint) - } - } - - func publishPortsSnapshotLocked() { - let controllerID = self.controllerID - let detectedByPanel = remotePortScanTTYNames.keys.reduce(into: [UUID: [Int]]()) { result, panelId in - result[panelId] = remoteScannedPortsByPanel[panelId] ?? [] - } - let detected = Array( - Set(polledRemotePorts) - .union(detectedByPanel.values.flatMap { $0 }) - ).sorted() - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteDetectedSurfacePortsSnapshot( - detectedByPanel: detectedByPanel, - detected: detected, - forwarded: [], - conflicts: [], - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - func recordHeartbeatActivityLocked() { - heartbeatCount += 1 - publishHeartbeat(count: heartbeatCount, at: Date()) - } - - func publishHeartbeat(count: Int, at date: Date?) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) - } - } - - func requestBootstrapRemoteTTYIfNeededLocked() { - guard !bootstrapRemoteTTYResolved else { return } - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - if !remotePortScanTTYNames.isEmpty { - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - return - } - guard !bootstrapRemoteTTYFetchInFlight else { return } - bootstrapRemoteTTYFetchInFlight = true - defer { bootstrapRemoteTTYFetchInFlight = false } - - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted("tty_path=\"$HOME/.programa/relay/\(relayPort).tty\"; if [ -r \"$tty_path\" ]; then cat \"$tty_path\"; fi"))" - do { - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 2 - ) - guard result.status == 0 else { - scheduleBootstrapRemoteTTYRetryLocked() - return - } - guard let ttyName = Self.normalizedRemotePortScanTTYName(result.stdout) else { - scheduleBootstrapRemoteTTYRetryLocked() - return - } - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - debugLog("remote.tty.bootstrap.ready tty=\(ttyName) \(debugConfigSummary())") - publishBootstrapRemoteTTY(ttyName) - } catch { - debugLog("remote.tty.bootstrap.failed error=\(error.localizedDescription) \(debugConfigSummary())") - scheduleBootstrapRemoteTTYRetryLocked() - } - } - - func scheduleBootstrapRemoteTTYRetryLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - guard !bootstrapRemoteTTYResolved else { return } - guard remotePortScanTTYNames.isEmpty else { return } - guard bootstrapRemoteTTYRetryCount < Self.bootstrapRemoteTTYRetryLimit else { return } - guard bootstrapRemoteTTYRetryWorkItem == nil else { return } - - bootstrapRemoteTTYRetryCount += 1 - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.bootstrapRemoteTTYRetryWorkItem = nil - self.requestBootstrapRemoteTTYIfNeededLocked() - } - bootstrapRemoteTTYRetryWorkItem = workItem - queue.asyncAfter(deadline: .now() + Self.bootstrapRemoteTTYRetryDelay, execute: workItem) - } - - func publishBootstrapRemoteTTY(_ ttyName: String) { - let controllerID = self.controllerID - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - guard workspace.activeRemoteSessionControllerID == controllerID else { return } - workspace.applyBootstrapRemoteTTY(ttyName) - } - } - - func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { - // Fallback standalone transport when dynamic forwarding through an existing - // control master is unavailable. - var args: [String] = ["-N", "-T", "-S", "none"] - args += sshCommonArguments(batchMode: true) - args += [ - "-o", "ExitOnForwardFailure=yes", - "-o", "RequestTTY=no", - "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", - configuration.destination, - ] - return args - } - - func startReverseRelayViaControlMasterLocked(forwardSpec: String) -> Bool { - guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "forward", - forwardSpec: forwardSpec - ) else { - return false - } - - do { - let result = try sshExec(arguments: arguments, timeout: 6) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) - ?? "ssh exited \(result.status)" - debugLog("remote.relay.controlmaster.forwardFailed \(detail) \(debugConfigSummary())") - return false - } - reverseRelayControlMasterForwardSpec = forwardSpec - return true - } catch { - debugLog("remote.relay.controlmaster.forwardFailed \(error.localizedDescription) \(debugConfigSummary())") - return false - } - } - - func stopReverseRelayViaControlMasterLocked() { - guard let forwardSpec = reverseRelayControlMasterForwardSpec else { return } - reverseRelayControlMasterForwardSpec = nil - guard let arguments = WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "cancel", - forwardSpec: forwardSpec - ) else { - return - } - _ = try? sshExec(arguments: arguments, timeout: 4) - } - - static let bootstrapRemoteTTYRetryDelay: TimeInterval = 0.5 - static let bootstrapRemoteTTYRetryLimit = 8 - -} diff --git a/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift b/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift deleted file mode 100644 index 52964ef8..00000000 --- a/Sources/WorkspaceRemoteSessionController+DaemonInstall.swift +++ /dev/null @@ -1,686 +0,0 @@ -// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): remote daemon bootstrap/build/download/upload and file-drop upload. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -final class RemoteDaemonTransferResult<Value>: @unchecked Sendable { - private let lock = NSLock() - private var stored: Result<Value, Error>? - private var isCancelled = false - - func storeIfActive(_ makeResult: () -> Result<Value, Error>) { - lock.lock() - if !isCancelled, stored == nil { - stored = makeResult() - } - lock.unlock() - } - - func cancel() { - lock.lock() - isCancelled = true - lock.unlock() - } - - func result() -> Result<Value, Error>? { - lock.lock() - defer { lock.unlock() } - return stored - } -} - -extension WorkspaceRemoteSessionController { - static let remotePlatformProbeOSMarker = "__PROGRAMA_REMOTE_OS__=" - static let remotePlatformProbeArchMarker = "__PROGRAMA_REMOTE_ARCH__=" - static let remotePlatformProbeExistsMarker = "__PROGRAMA_REMOTE_EXISTS__=" - - func bootstrapDaemonLocked() throws -> DaemonHello { - debugLog("remote.bootstrap.begin \(debugConfigSummary())") - let version = Self.remoteDaemonVersion() - let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) - let platform = bootstrapState.platform - let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() - let forceExplicitOverrideInstall = explicitOverrideBinary != nil - debugLog( - "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + - "version=\(version) remotePath=\(remotePath) " + - "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + - "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" - ) - - let hadExistingBinary = bootstrapState.binaryExists - debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") - if forceExplicitOverrideInstall || !hadExistingBinary { - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath, currentVersion: version) - } - - var hello: DaemonHello - do { - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } catch { - guard hadExistingBinary else { - throw error - } - debugLog( - "remote.bootstrap.helloRetry remotePath=\(remotePath) " + - "detail=\(error.localizedDescription)" - ) - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath, currentVersion: version) - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } - if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { - debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath, currentVersion: version) - hello = try helloRemoteDaemonLocked(remotePath: remotePath) - } - - debugLog( - "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + - "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" - ) - if let connectionAttemptStartedAt { - debugLog( - "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + - "\(debugConfigSummary())" - ) - } - return hello - } - - func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { - if let cliRelayServer { - return cliRelayServer - } - let relayServer = try WorkspaceRemoteCLIRelayServer( - localSocketPath: localSocketPath, - relayID: relayID, - relayTokenHex: relayToken - ) - cliRelayServer = relayServer - return relayServer - } - - func installRemoteRelayMetadataLocked( - remotePath: String, - relayPort: Int, - relayID: String, - relayToken: String - ) throws { - let script = Self.remoteRelayMetadataInstallScript( - daemonRemotePath: remotePath, - relayPort: relayPort, - relayID: relayID, - relayToken: relayToken - ) - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.relay", code: 70, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", - ]) - } - } - - func removeRemoteRelayMetadataLocked() { - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - do { - _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - } catch { - debugLog("remote.relay.cleanup.error \(error.localizedDescription)") - } - } - - static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { - """ - relay_socket='127.0.0.1:\(relayPort)' - socket_addr_file="$HOME/.programa/socket_addr" - if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then - rm -f "$socket_addr_file" - fi - rm -f "$HOME/.programa/relay/\(relayPort).auth" "$HOME/.programa/relay/\(relayPort).daemon_path" "$HOME/.programa/relay/\(relayPort).tty" - """ - } - - func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { - let script = """ - programa_uname_os="$(uname -s)" - programa_uname_arch="$(uname -m)" - printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$programa_uname_os" - printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$programa_uname_arch" - case "$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" in - linux|darwin|freebsd) programa_go_os="$(printf '%s' "$programa_uname_os" | tr '[:upper:]' '[:lower:]')" ;; - *) exit 70 ;; - esac - case "$(printf '%s' "$programa_uname_arch" | tr '[:upper:]' '[:lower:]')" in - x86_64|amd64) programa_go_arch=amd64 ;; - aarch64|arm64) programa_go_arch=arm64 ;; - armv7l) programa_go_arch=arm ;; - *) exit 71 ;; - esac - programa_remote_path="$HOME/.programa/bin/programad-remote/\(version)/${programa_go_os}-${programa_go_arch}/programad-remote" - if [ -x "$programa_remote_path" ]; then - printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' - else - printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' - fi - """ - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) - - let lines = result.stdout - .split(separator: "\n", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } - let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } - guard let unameOS, let unameArch else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", - ]) - } - - guard let goOS = Self.mapUnameOS(unameOS), - let goArch = Self.mapUnameArch(unameArch) else { - throw NSError(domain: "programa.remote.daemon", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", - ]) - } - - let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } - .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } - if result.status != 0, binaryExists == nil { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 13, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", - ]) - } - - return RemoteBootstrapState( - platform: RemotePlatform(goOS: goOS, goArch: goArch), - binaryExists: binaryExists ?? false - ) - } - - static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" - - static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { - guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } - let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let data = trimmed.data(using: .utf8) else { return nil } - return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) - } - - static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { - remoteDaemonManifest(from: Bundle.main.infoDictionary) - } - - static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { - let appSupportRoot = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let cacheRoot = appSupportRoot - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("remote-daemons", isDirectory: true) - try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) - return cacheRoot - } - - static func remoteDaemonCachedBinaryURL( - version: String, - goOS: String, - goArch: String, - fileManager: FileManager = .default - ) throws -> URL { - try remoteDaemonCacheRoot(fileManager: fileManager) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - static func sha256Hex(forFile url: URL) throws -> String { - let data = try Data(contentsOf: url) - let digest = SHA256.hash(data: data) - return digest.map { String(format: "%02x", $0) }.joined() - } - - static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { - environment["PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" - } - - static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { - guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } - guard let path = environment["PROGRAMA_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !path.isEmpty else { - return nil - } - return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL - } - - static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { - URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("programa-remote-daemon-build", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - .appendingPathComponent("programad-remote", isDirectory: false) - } - - static func preserveRemoteDaemonDownload( - temporaryURL: URL?, - response: URLResponse?, - error: Error?, - destinationURL: URL, - fileManager: FileManager = .default - ) -> Result<URL, Error> { - if let error { - return .failure(error) - } - if let httpResponse = response as? HTTPURLResponse, - !(200 ... 299).contains(httpResponse.statusCode) { - return .failure(NSError(domain: "programa.remote.daemon", code: 26, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", - ])) - } - guard let temporaryURL else { - return .failure(NSError(domain: "programa.remote.daemon", code: 27, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download did not produce a file", - ])) - } - - do { - try? fileManager.removeItem(at: destinationURL) - try fileManager.moveItem(at: temporaryURL, to: destinationURL) - return .success(destinationURL) - } catch { - return .failure(error) - } - } - - /// Fetch the live manifest JSON from the release, returning nil on any failure. - static func fetchRemoteManifestLocked(releaseURL: String, version: String) -> WorkspaceRemoteDaemonManifest? { - guard let manifestURL = URL(string: "\(releaseURL)/programad-remote-manifest.json") else { return nil } - let request = NSMutableURLRequest(url: manifestURL) - request.timeoutInterval = 15 - request.setValue("programa/\(version)", forHTTPHeaderField: "User-Agent") - let session = URLSession(configuration: .ephemeral) - let semaphore = DispatchSemaphore(value: 0) - let result = RemoteDaemonTransferResult<Data>() - let task = session.dataTask(with: request as URLRequest) { data, response, error in - defer { semaphore.signal() } - if let error { - result.storeIfActive { .failure(error) } - return - } - guard let httpResponse = response as? HTTPURLResponse, - (200 ... 299).contains(httpResponse.statusCode), - let data else { - result.storeIfActive { - .failure(NSError(domain: "programa.remote.daemon", code: 30, userInfo: nil)) - } - return - } - result.storeIfActive { .success(data) } - } - task.resume() - guard semaphore.wait(timeout: .now() + 20.0) == .success else { - result.cancel() - task.cancel() - session.invalidateAndCancel() - return nil - } - session.finishTasksAndInvalidate() - guard case .success(let data)? = result.result() else { return nil } - return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) - } - - func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String, releaseURL: String? = nil) throws -> URL { - guard let url = URL(string: entry.downloadURL) else { - throw NSError(domain: "programa.remote.daemon", code: 25, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", - ]) - } - - let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) - let fileManager = FileManager.default - try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) - - let request = NSMutableURLRequest(url: url) - request.timeoutInterval = 60 - request.setValue("programa/\(version)", forHTTPHeaderField: "User-Agent") - let session = URLSession(configuration: .ephemeral) - - let semaphore = DispatchSemaphore(value: 0) - let transferResult = RemoteDaemonTransferResult<URL>() - let ownedDownloadURL = cacheURL.deletingLastPathComponent() - .appendingPathComponent(".\(cacheURL.lastPathComponent).download-\(UUID().uuidString)") - let task = session.downloadTask(with: request as URLRequest) { localURL, response, error in - defer { semaphore.signal() } - transferResult.storeIfActive { - Self.preserveRemoteDaemonDownload( - temporaryURL: localURL, - response: response, - error: error, - destinationURL: ownedDownloadURL, - fileManager: fileManager - ) - } - } - task.resume() - guard semaphore.wait(timeout: .now() + 75.0) == .success else { - transferResult.cancel() - task.cancel() - session.invalidateAndCancel() - try? fileManager.removeItem(at: ownedDownloadURL) - throw NSError(domain: "programa.remote.daemon", code: 29, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download timed out", - ]) - } - session.finishTasksAndInvalidate() - - guard let completedTransfer = transferResult.result() else { - throw NSError(domain: "programa.remote.daemon", code: 27, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon download did not produce a file", - ]) - } - let downloadedURL = try completedTransfer.get() - defer { try? fileManager.removeItem(at: downloadedURL) } - - let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) - if downloadedSHA != entry.sha256.lowercased() { - // The embedded manifest's checksum doesn't match the downloaded binary. - // This can happen when a newer build overwrites the shared release - // asset after this build's manifest was embedded. As a fallback, fetch - // the live manifest from the release and verify against that. - if let releaseURL, - let liveManifest = Self.fetchRemoteManifestLocked(releaseURL: releaseURL, version: version), - let liveEntry = liveManifest.entry(goOS: entry.goOS, goArch: entry.goArch), - downloadedSHA == liveEntry.sha256.lowercased() { - debugLog("remote.download.checksum-fallback: embedded manifest checksum stale, live manifest matched for \(entry.assetName)") - } else { - throw NSError(domain: "programa.remote.daemon", code: 28, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", - ]) - } - } - - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: downloadedURL.path) - try? fileManager.removeItem(at: cacheURL) - try fileManager.moveItem(at: downloadedURL, to: cacheURL) - return cacheURL - } - - func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { - if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), - FileManager.default.isExecutableFile(atPath: explicitBinary.path) { - debugLog("remote.build.explicit path=\(explicitBinary.path)") - return explicitBinary - } - - if let manifest = Self.remoteDaemonManifest(), - manifest.appVersion == version, - let entry = manifest.entry(goOS: goOS, goArch: goArch) { - let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) - if FileManager.default.fileExists(atPath: cacheURL.path) { - let cachedSHA = try Self.sha256Hex(forFile: cacheURL) - if cachedSHA == entry.sha256.lowercased(), - FileManager.default.isExecutableFile(atPath: cacheURL.path) { - debugLog("remote.build.cached path=\(cacheURL.path)") - return cacheURL - } - try? FileManager.default.removeItem(at: cacheURL) - } - let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion, releaseURL: manifest.releaseURL) - debugLog("remote.build.downloaded path=\(downloadedURL.path)") - return downloadedURL - } - - guard Self.allowLocalDaemonBuildFallback() else { - throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: String( - format: String( - localized: "remoteDaemon.error.missingVerifiedManifest", - defaultValue: "This build does not include a verified programad-remote manifest for %@-%@. Use a release build, or set PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback." - ), - goOS, - goArch - ), - ]) - } - - guard let repoRoot = Self.findRepoRoot() else { - throw NSError(domain: "programa.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: String( - localized: "remoteDaemon.error.repoRootNotFound", - defaultValue: "Cannot locate the Programa repository root for the development-only programad-remote build fallback." - ), - ]) - } - let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) - let goModPath = daemonRoot.appendingPathComponent("go.mod").path - guard FileManager.default.fileExists(atPath: goModPath) else { - throw NSError(domain: "programa.remote.daemon", code: 21, userInfo: [ - NSLocalizedDescriptionKey: String( - format: String( - localized: "remoteDaemon.error.missingDaemonModule", - defaultValue: "Missing daemon module at %@." - ), - goModPath - ), - ]) - } - guard let goBinary = Self.which("go") else { - throw NSError(domain: "programa.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: String( - localized: "remoteDaemon.error.goRequired", - defaultValue: "Go is required for the development-only programad-remote build fallback." - ), - ]) - } - - let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) - try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) - - var env = ProcessInfo.processInfo.environment - env["GOOS"] = goOS - env["GOARCH"] = goArch - env["CGO_ENABLED"] = "0" - let ldflags = "-s -w -X main.version=\(version)" - let result = try runProcess( - executable: goBinary, - arguments: ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/programad-remote"], - environment: env, - currentDirectory: daemonRoot, - stdin: nil, - timeout: 90 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 23, userInfo: [ - NSLocalizedDescriptionKey: "failed to build programad-remote: \(detail)", - ]) - } - guard FileManager.default.isExecutableFile(atPath: output.path) else { - throw NSError(domain: "programa.remote.daemon", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "programad-remote build output is not executable", - ]) - } - debugLog("remote.build.output path=\(output.path)") - return output - } - - func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String, currentVersion: String) throws { - let remoteDirectory = (remotePath as NSString).deletingLastPathComponent - let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" - debugLog( - "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" - ) - - let mkdirScript = "mkdir -p \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteDirectory))" - let mkdirCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(mkdirScript))" - let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) - guard mkdirResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" - throw NSError(domain: "programa.remote.daemon", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", - ]) - } - - let scpArgs = WorkspaceRemoteSSHBatchCommandBuilder.scpUploadArguments( - configuration: configuration, - localPath: localBinary.path, - remotePath: remoteTempPath - ) - let scpResult = try scpExec(arguments: scpArgs, timeout: 45) - guard scpResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" - throw NSError(domain: "programa.remote.daemon", code: 31, userInfo: [ - NSLocalizedDescriptionKey: "failed to upload programad-remote: \(detail)", - ]) - } - - // Prune runs only once chmod+mv have confirmed the current version's - // binary is in place, and its own failure must never fail the install: - // it is wrapped in a subshell with `|| true`, which is also the last - // command in the `then` branch, so a successful install always exits 0 - // regardless of what pruning does. A failed chmod/mv skips pruning - // entirely and exits 1 via the `else` branch, so a prune bug can never - // be mistaken for (or mask) an install failure. - let finalizeScript = """ - if chmod 755 \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) && \ - mv \(RemoteSSHConnectionPolicy.shellSingleQuoted(remoteTempPath)) \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)); then - ( - \(Self.remoteDaemonPruneStaleVersionsScript(currentVersion: currentVersion)) - ) || true - else - exit 1 - fi - """ - let finalizeCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(finalizeScript))" - let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) - guard finalizeResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" - throw NSError(domain: "programa.remote.daemon", code: 32, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", - ]) - } - } - - func uploadDroppedFilesLocked( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation - ) throws -> [String] { - return try performSCPUploadWithCancelCleanup( - items: fileURLs, - checkCancelled: { try operation.throwIfCancelled() }, - performUpload: { localURL, record in - let normalizedLocalURL = localURL.standardizedFileURL - guard normalizedLocalURL.isFileURL else { - throw RemoteDropUploadError.invalidFileURL - } - - let remotePath = Self.remoteDropPath(for: normalizedLocalURL) - record(remotePath) - let scpArgs = WorkspaceRemoteSSHBatchCommandBuilder.scpUploadArguments( - configuration: configuration, - localPath: normalizedLocalURL.path, - remotePath: remotePath - ) - - let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) - guard scpResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? - "scp exited \(scpResult.status)" - throw RemoteDropUploadError.uploadFailed(detail) - } - }, - cleanup: { cleanupUploadedRemotePaths($0) } - ) - } - - static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { - let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" - return "/tmp/programa-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" - } - - func cleanupUploadedRemotePaths(_ remotePaths: [String]) { - guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(RemoteSSHConnectionPolicy.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(cleanupScript))" - _ = try? sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], - timeout: 8 - ) - } - - func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { - let request = #"{"id":1,"method":"hello","params":{}}"# - let script = "printf '%s\\n' \(RemoteSSHConnectionPolicy.shellSingleQuoted(request)) | \(RemoteSSHConnectionPolicy.shellSingleQuoted(remotePath)) serve --stdio" - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.daemon", code: 40, userInfo: [ - NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", - ]) - } - - let responseLine = result.stdout - .split(separator: "\n") - .map(String.init) - .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" - guard !responseLine.isEmpty, - let data = responseLine.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { - throw NSError(domain: "programa.remote.daemon", code: 41, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", - ]) - } - - if let ok = payload["ok"] as? Bool, !ok { - let errorMessage: String = { - if let errorObject = payload["error"] as? [String: Any], - let message = errorObject["message"] as? String, - !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return message - } - return "hello call failed" - }() - throw NSError(domain: "programa.remote.daemon", code: 42, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", - ]) - } - - let resultObject = payload["result"] as? [String: Any] ?? [:] - let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let capabilities = (resultObject["capabilities"] as? [String]) ?? [] - return DaemonHello( - name: (name?.isEmpty == false ? name! : "programad-remote"), - version: (version?.isEmpty == false ? version! : "dev"), - capabilities: capabilities, - remotePath: remotePath - ) - } - -} diff --git a/Sources/WorkspaceRemoteSessionController+PortScanning.swift b/Sources/WorkspaceRemoteSessionController+PortScanning.swift deleted file mode 100644 index b58289f2..00000000 --- a/Sources/WorkspaceRemoteSessionController+PortScanning.swift +++ /dev/null @@ -1,489 +0,0 @@ -// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): remote listening-port scan scheduling, polling, and script builders. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -extension WorkspaceRemoteSessionController { - func updateRemotePortScanTTYs(_ ttyNames: [UUID: String]) { - queue.async { [weak self] in - self?.updateRemotePortScanTTYsLocked(ttyNames) - } - } - - func kickRemotePortScan(panelId: UUID, reason: PortScanKickReason = .command) { - queue.async { [weak self] in - self?.kickRemotePortScanLocked(panelId: panelId, reason: reason) - } - } - - func updateRemotePortScanTTYsLocked(_ ttyNames: [UUID: String]) { - let previousTTYNames = remotePortScanTTYNames - let nextTTYNames = ttyNames.reduce(into: [UUID: String]()) { result, entry in - guard let ttyName = Self.normalizedRemotePortScanTTYName(entry.value) else { return } - result[entry.key] = ttyName - } - guard previousTTYNames != nextTTYNames else { return } - if !nextTTYNames.isEmpty { - bootstrapRemoteTTYResolved = true - bootstrapRemoteTTYRetryWorkItem?.cancel() - bootstrapRemoteTTYRetryWorkItem = nil - bootstrapRemoteTTYRetryCount = 0 - } - keepPolledRemotePortsUntilTTYScan = - !previousTTYNames.isEmpty - ? keepPolledRemotePortsUntilTTYScan - : shouldUseFallbackRemotePortPollingLocked() && !polledRemotePorts.isEmpty && !nextTTYNames.isEmpty - remoteScannedPortsByPanel = remoteScannedPortsByPanel.filter { panelId, _ in - guard let oldTTY = previousTTYNames[panelId], - let newTTY = nextTTYNames[panelId] else { - return false - } - return oldTTY == newTTY - } - remotePortScanTTYNames = nextTTYNames - if nextTTYNames.isEmpty { - keepPolledRemotePortsUntilTTYScan = false - } - updateRemotePortPollingStateLocked() - publishPortsSnapshotLocked() - } - - func kickRemotePortScanLocked(panelId: UUID, reason: PortScanKickReason) { - guard !isStopping else { return } - guard daemonReady else { return } - guard remotePortScanTTYNames[panelId] != nil else { return } - if remotePortScanBurstActive, remotePortScanActiveReason == .command, reason == .refresh { - return - } - remotePortScanPendingReason = remotePortScanPendingReason?.merged(with: reason) ?? reason - scheduleRemotePortScanCoalesceLocked() - } - - func scheduleRemotePortScanCoalesceLocked() { - guard !remotePortScanBurstActive else { return } - guard remotePortScanCoalesceWorkItem == nil else { return } - - let generation = remotePortScanGeneration - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - guard self.remotePortScanGeneration == generation else { return } - self.remotePortScanCoalesceWorkItem = nil - guard let reason = self.remotePortScanPendingReason else { return } - self.remotePortScanPendingReason = nil - self.remotePortScanBurstActive = true - self.remotePortScanActiveReason = reason - self.runRemotePortScanBurstLocked(index: 0, generation: generation, reason: reason) - } - remotePortScanCoalesceWorkItem = workItem - queue.asyncAfter(deadline: .now() + 0.2, execute: workItem) - } - - func runRemotePortScanBurstLocked( - index: Int, - generation: UInt64, - reason: PortScanKickReason, - burstStart: DispatchTime? = nil - ) { - guard remotePortScanGeneration == generation else { return } - - let burstOffsets = reason.burstOffsets - guard index < burstOffsets.count else { - remotePortScanBurstActive = false - remotePortScanActiveReason = nil - if remotePortScanPendingReason != nil && remotePortScanCoalesceWorkItem == nil { - scheduleRemotePortScanCoalesceLocked() - } - return - } - - let start = burstStart ?? .now() - let deadline = start + burstOffsets[index] - queue.asyncAfter(deadline: deadline) { [weak self] in - guard let self else { return } - guard self.remotePortScanGeneration == generation else { return } - self.performRemotePortScanLocked() - self.runRemotePortScanBurstLocked( - index: index + 1, - generation: generation, - reason: reason, - burstStart: start - ) - } - } - - func performRemotePortScanLocked() { - let ttyNamesByPanel = remotePortScanTTYNames - guard !ttyNamesByPanel.isEmpty else { - remoteScannedPortsByPanel.removeAll() - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - return - } - - do { - remoteScannedPortsByPanel = try scanRemotePortsByPanelLocked(ttyNamesByPanel: ttyNamesByPanel) - keepPolledRemotePortsUntilTTYScan = false - polledRemotePorts = [] - publishPortsSnapshotLocked() - } catch { - debugLog("remote.ports.scan.failed error=\(error.localizedDescription) \(debugConfigSummary())") - } - } - - func scanRemotePortsByPanelLocked(ttyNamesByPanel: [UUID: String]) throws -> [UUID: [Int]] { - let ttyNames = Array(Set(ttyNamesByPanel.values)).sorted() - guard !ttyNames.isEmpty else { return [:] } - - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remotePortScanScript(ttyNames: ttyNames, excluding: excludedRemoteScanPorts())))" - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 8 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ - NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", - ]) - } - - let portsByTTY = Self.parseRemoteTTYPortPairs( - output: result.stdout, - trackedTTYNames: Set(ttyNames) - ) - - return ttyNamesByPanel.reduce(into: [UUID: [Int]]()) { result, entry in - result[entry.key] = portsByTTY[entry.value] ?? [] - } - } - - func startRemotePortPollingLocked(mode: RemotePortPollingMode) { - if remotePortPollTimer != nil, remotePortPollMode == mode { - return - } - stopRemotePortPollingLocked() - - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + mode.initialDelay, repeating: mode.repeatInterval) - timer.setEventHandler { [weak self] in - self?.pollRemotePortsLocked() - } - remotePortPollTimer = timer - remotePortPollMode = mode - timer.resume() - pollRemotePortsLocked() - } - - func stopRemotePortPollingLocked() { - remotePortPollTimer?.setEventHandler {} - remotePortPollTimer?.cancel() - remotePortPollTimer = nil - remotePortPollMode = nil - } - - func updateRemotePortPollingStateLocked() { - guard daemonReady, !isStopping, let pollingMode = remotePortPollingModeLocked() else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - remotePortPollBaselinePorts = nil - return - } - startRemotePortPollingLocked(mode: pollingMode) - } - - func pollRemotePortsLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - if !remotePortScanTTYNames.isEmpty { - guard shouldUseTTYFallbackRemotePortPollingLocked() else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - publishPortsSnapshotLocked() - return - } - if remotePortScanBurstActive || remotePortScanCoalesceWorkItem != nil || remotePortScanPendingReason != nil { - return - } - performRemotePortScanLocked() - return - } - guard let pollingMode = remotePortPollingModeLocked() else { - stopRemotePortPollingLocked() - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - return - } - guard remotePortScanTTYNames.isEmpty else { - stopRemotePortPollingLocked() - if !keepPolledRemotePortsUntilTTYScan { - polledRemotePorts = [] - } - remotePortPollBaselinePorts = nil - publishPortsSnapshotLocked() - return - } - - let command = "sh -c \(RemoteSSHConnectionPolicy.shellSingleQuoted(Self.remoteAllPortsScanScript(excluding: excludedRemoteScanPorts())))" - do { - let result = try sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], - timeout: 8 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "programa.remote.ports", code: 90, userInfo: [ - NSLocalizedDescriptionKey: "remote port scan failed: \(detail)", - ]) - } - let currentPorts = Set(Self.parseRemotePorts(output: result.stdout)) - switch pollingMode { - case .hostWide: - polledRemotePorts = currentPorts.sorted() - remotePortPollBaselinePorts = nil - case .hostWideDelta: - if let baselinePorts = remotePortPollBaselinePorts { - polledRemotePorts = currentPorts.subtracting(baselinePorts).sorted() - } else { - remotePortPollBaselinePorts = currentPorts - polledRemotePorts = [] - } - case .ttyScoped: - polledRemotePorts = [] - remotePortPollBaselinePorts = nil - } - keepPolledRemotePortsUntilTTYScan = false - publishPortsSnapshotLocked() - } catch { - debugLog("remote.ports.poll.failed error=\(error.localizedDescription) \(debugConfigSummary())") - } - } - - func excludedRemoteScanPorts() -> Set<Int> { - var excluded: Set<Int> = [] - if let relayPort = configuration.relayPort, relayPort > 0 { - excluded.insert(relayPort) - } - if let configuredPort = configuration.port, configuredPort > 0 { - excluded.insert(configuredPort) - } - return excluded - } - - func shouldUseFallbackRemotePortPollingLocked() -> Bool { - // `cmux ssh` owns the remote shell bootstrap and can report the remote - // TTY precisely. Falling back to host-wide port scans in that path leaks - // unrelated listeners from the remote machine into the workspace card. - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - return startupCommand?.isEmpty != false - } - - func shouldUseTTYFallbackRemotePortPollingLocked() -> Bool { - // `cmux ssh` can still land in shells without our command hooks, such as - // `/bin/sh` in the Docker fixture. Once the workspace knows the TTY, - // keep a low-frequency TTY-scoped poll so unsupported shells still - // surface ports without bringing back noisy host-wide scans. - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - return startupCommand?.isEmpty == false - } - - func remotePortPollingModeLocked() -> RemotePortPollingMode? { - if !remotePortScanTTYNames.isEmpty { - return shouldUseTTYFallbackRemotePortPollingLocked() ? .ttyScoped : nil - } - let startupCommand = configuration.terminalStartupCommand? - .trimmingCharacters(in: .whitespacesAndNewlines) - if startupCommand?.isEmpty == false { - return .hostWideDelta - } - return shouldUseFallbackRemotePortPollingLocked() ? .hostWide : nil - } - - static func parseRemoteTTYPortPairs(output: String, trackedTTYNames: Set<String>) -> [String: [Int]] { - var portsByTTY = Dictionary(uniqueKeysWithValues: trackedTTYNames.map { ($0, Set<Int>()) }) - - for line in output.split(separator: "\n") { - let parts = line.split(separator: "\t", omittingEmptySubsequences: false) - guard parts.count == 2 else { continue } - let ttyName = String(parts[0]).trimmingCharacters(in: .whitespacesAndNewlines) - guard trackedTTYNames.contains(ttyName), - let port = Int(parts[1]), - port >= 1024, - port <= 65535 else { - continue - } - portsByTTY[ttyName, default: []].insert(port) - } - - return portsByTTY.reduce(into: [String: [Int]]()) { result, entry in - result[entry.key] = entry.value.sorted() - } - } - - static func parseRemotePorts(output: String) -> [Int] { - let values = output - .split(whereSeparator: \.isWhitespace) - .compactMap { Int($0) } - .filter { $0 >= 1024 && $0 <= 65535 } - return Array(Set(values)).sorted() - } - - static func normalizedRemotePortScanTTYName(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let candidate = trimmed.split(separator: "/").last.map(String.init) ?? trimmed - guard !candidate.isEmpty else { return nil } - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - guard candidate.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } - return candidate - } - - static func remotePortScanScript(ttyNames: [String], excluding ports: Set<Int>) -> String { - let ttySet = ttyNames.joined(separator: " ") - let ttyCSV = ttyNames.joined(separator: ",") - let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") - - return """ - set -eu - programa_tracked_ttys=" \(ttySet) " - programa_tty_csv='\(ttyCSV)' - programa_excluded_ports=" \(excludedPorts) " - - programa_emit_port() { - programa_tty="$1" - programa_port="$2" - case "$programa_tracked_ttys" in - *" $programa_tty "*) ;; - *) return 0 ;; - esac - case "$programa_excluded_ports" in - *" $programa_port "*) return 0 ;; - esac - [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 - printf '%s\\t%s\\n' "$programa_tty" "$programa_port" - } - - programa_used_ss=0 - if [ -d /proc ] && command -v ss >/dev/null 2>&1; then - programa_ss_output="$(ss -ltnpH 2>/dev/null || true)" - case "$programa_ss_output" in - *pid=*) - programa_used_ss=1 - printf '%s\\n' "$programa_ss_output" | while IFS= read -r programa_line; do - [ -n "$programa_line" ] || continue - programa_port="$(printf '%s\\n' "$programa_line" | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ { print $1; exit }')" - [ -n "$programa_port" ] || continue - printf '%s\\n' "$programa_line" | awk ' - { - line = $0 - while (match(line, /pid=[0-9]+/)) { - print substr(line, RSTART + 4, RLENGTH - 4) - line = substr(line, RSTART + RLENGTH) - } - } - ' | while IFS= read -r programa_pid; do - [ -n "$programa_pid" ] || continue - programa_tty_path="$(readlink "/proc/$programa_pid/fd/0" 2>/dev/null || true)" - [ -n "$programa_tty_path" ] || continue - programa_tty="${programa_tty_path##*/}" - [ -n "$programa_tty" ] || continue - programa_emit_port "$programa_tty" "$programa_port" - done - done - ;; - esac - fi - - if [ "$programa_used_ss" -eq 0 ] && command -v lsof >/dev/null 2>&1 && [ -n "$programa_tty_csv" ]; then - programa_tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t programa-ports)" - trap 'rm -rf "$programa_tmpdir"' EXIT INT TERM - programa_pid_tty_map="$programa_tmpdir/pid_tty" - ps -t "$programa_tty_csv" -o pid=,tty= 2>/dev/null | awk ' - NF >= 2 { - tty = $2 - sub(/^.*\\//, "", tty) - print $1 "\\t" tty - } - ' > "$programa_pid_tty_map" - [ -s "$programa_pid_tty_map" ] || exit 0 - programa_pid_csv="$(awk '{print $1}' "$programa_pid_tty_map" | paste -sd, -)" - [ -n "$programa_pid_csv" ] || exit 0 - lsof -nP -a -p "$programa_pid_csv" -iTCP -sTCP:LISTEN -Fpn 2>/dev/null | awk -v map="$programa_pid_tty_map" ' - BEGIN { - while ((getline < map) > 0) { - pid_to_tty[$1] = $2 - } - close(map) - } - $0 ~ /^p/ { - pid = substr($0, 2) - tty = pid_to_tty[pid] - next - } - $0 ~ /^n/ && tty != "" { - name = substr($0, 2) - sub(/->.*/, "", name) - sub(/^.*:/, "", name) - sub(/[^0-9].*/, "", name) - if (name != "") { - print tty "\\t" name - } - } - ' | while IFS=$'\\t' read -r programa_tty programa_port; do - [ -n "$programa_tty" ] || continue - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_tty" "$programa_port" - done - fi - """ - } - - static func remoteAllPortsScanScript(excluding ports: Set<Int>) -> String { - let excludedPorts = ports.sorted().map(String.init).joined(separator: " ") - - return """ - set -eu - programa_excluded_ports=" \(excludedPorts) " - - programa_emit_port() { - programa_port="$1" - case "$programa_excluded_ports" in - *" $programa_port "*) return 0 ;; - esac - [ "$programa_port" -ge 1024 ] && [ "$programa_port" -le 65535 ] || return 0 - printf '%s\\n' "$programa_port" - } - - if command -v ss >/dev/null 2>&1; then - ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - elif command -v netstat >/dev/null 2>&1; then - netstat -lnt 2>/dev/null | awk 'NR > 2 {print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - elif command -v lsof >/dev/null 2>&1; then - lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR > 1 {print $9}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | while IFS= read -r programa_port; do - [ -n "$programa_port" ] || continue - programa_emit_port "$programa_port" - done - fi - """ - } - -} diff --git a/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift b/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift deleted file mode 100644 index 6d1bd623..00000000 --- a/Sources/WorkspaceRemoteSessionController+ProcessExecution.swift +++ /dev/null @@ -1,193 +0,0 @@ -// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): ssh/scp argument assembly and the underlying Process execution primitive. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -extension WorkspaceRemoteSessionController { - func sshCommonArguments(batchMode: Bool) -> [String] { - let effectiveSSHOptions: [String] = { - if batchMode { - return RemoteSSHConnectionPolicy.backgroundOptions(configuration.sshOptions) - } - return RemoteSSHConnectionPolicy.normalizedOptions(configuration.sshOptions) - }() - var args = RemoteSSHConnectionPolicy.keepaliveArguments - args += RemoteSSHConnectionPolicy.strictHostKeyCheckingArguments(unlessSetIn: effectiveSSHOptions) - if batchMode { - args += RemoteSSHConnectionPolicy.batchModeArguments - } - if let port = configuration.port { - args += ["-p", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in effectiveSSHOptions { - args += ["-o", option] - } - return args - } - - func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/ssh", - arguments: arguments, - stdin: stdin, - timeout: timeout - ) - } - - func scpExec( - arguments: [String], - timeout: TimeInterval = 30, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/scp", - arguments: arguments, - stdin: nil, - timeout: timeout, - operation: operation - ) - } - - func runProcess( - executable: String, - arguments: [String], - environment: [String: String]? = nil, - currentDirectory: URL? = nil, - stdin: Data?, - timeout: TimeInterval, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { - debugLog( - "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" - ) - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - if let environment { - process.environment = environment - } - if let currentDirectory { - process.currentDirectoryURL = currentDirectory - } - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - if stdin != nil { - process.standardInput = Pipe() - } else { - process.standardInput = FileHandle.nullDevice - } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - let captureQueue = DispatchQueue(label: "programa.remote.process.capture") - let exitSemaphore = DispatchSemaphore(value: 0) - var stdoutData = Data() - var stderrData = Data() - let captureGroup = DispatchGroup() - process.terminationHandler = { _ in - exitSemaphore.signal() - } - captureGroup.enter() - DispatchQueue.global(qos: .utility).async { - let data = stdoutHandle.readDataToEndOfFile() - captureQueue.sync { - stdoutData = data - } - captureGroup.leave() - } - captureGroup.enter() - DispatchQueue.global(qos: .utility).async { - let data = stderrHandle.readDataToEndOfFile() - captureQueue.sync { - stderrData = data - } - captureGroup.leave() - } - - do { - try operation?.throwIfCancelled() - try process.run() - } catch { - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - debugLog( - "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "error=\(error.localizedDescription)" - ) - throw NSError(domain: "programa.remote.process", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", - ]) - } - try? stdoutPipe.fileHandleForWriting.close() - try? stderrPipe.fileHandleForWriting.close() - operation?.installCancellationHandler { - if process.isRunning { - process.terminate() - } - } - defer { operation?.clearCancellationHandler() } - - if let stdin, let pipe = process.standardInput as? Pipe { - pipe.fileHandleForWriting.write(stdin) - try? pipe.fileHandleForWriting.close() - } - - func terminateProcessAndWait() { - process.terminate() - let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success - if !terminatedGracefully, process.isRunning { - _ = Darwin.kill(process.processIdentifier, SIGKILL) - process.waitUntilExit() - } - } - - let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success - if !didExitBeforeTimeout, process.isRunning { - if operation?.isCancelled == true { - terminateProcessAndWait() - throw TerminalImageTransferExecutionError.cancelled - } - terminateProcessAndWait() - debugLog( - "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" - ) - throw NSError(domain: "programa.remote.process", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", - ]) - } - - _ = captureGroup.wait(timeout: .now() + 2.0) - try? stdoutHandle.close() - try? stderrHandle.close() - let stdout = String(data: stdoutData, encoding: .utf8) ?? "" - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - if operation?.isCancelled == true { - throw TerminalImageTransferExecutionError.cancelled - } - debugLog( - "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + - "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + - "stderr=\(Self.debugLogSnippet(stderr))" - ) - return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) - } - - -} diff --git a/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift b/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift deleted file mode 100644 index d08023ea..00000000 --- a/Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift +++ /dev/null @@ -1,601 +0,0 @@ -// Extracted from WorkspaceRemoteSession.swift (nuclear-review #98): debug logging, remote shell script builders, and process/PID utilities. - -import Foundation -import SwiftUI -import AppKit -import Bonsplit -import Combine -import CryptoKit -import Darwin -import Network -import CoreText - -extension WorkspaceRemoteSessionController { - func debugLog(_ message: @autoclosure () -> String) { -#if DEBUG - dlog(message()) -#endif - } - - func debugConfigSummary() -> String { - let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" - return - "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + - "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + - "localSocket=\(configuration.localSocketPath ?? "nil") " + - "controlPath=\(controlPath)" - } - - func debugShellCommand(executable: String, arguments: [String]) -> String { - ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) - .map(RemoteSSHConnectionPolicy.shellSingleQuoted) - .joined(separator: " ") - } - - static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { - let loweredKey = key.lowercased() - for option in options { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - if parts.count == 2, - parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { - return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - } - } - return nil - } - - static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { - let normalized = text - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty else { return "\"\"" } - if normalized.count <= limit { - return normalized - } - return String(normalized.prefix(limit)) + "..." - } - - static func remoteCLIWrapperScript() -> String { - """ - #!/bin/sh - set -eu - - daemon="$HOME/.programa/bin/programad-remote-current" - socket_path="${PROGRAMA_SOCKET_PATH:-}" - if [ -z "$socket_path" ] && [ -r "$HOME/.programa/socket_addr" ]; then - socket_path="$(tr -d '\\r\\n' < "$HOME/.programa/socket_addr")" - fi - - if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then - relay_port="${socket_path##*:}" - relay_map="$HOME/.programa/relay/${relay_port}.daemon_path" - if [ -r "$relay_map" ]; then - mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" - if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then - daemon="$mapped_daemon" - fi - fi - fi - - exec "$daemon" "$@" - """ - } - - static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - return """ - mkdir -p "$HOME/.programa/bin" "$HOME/.programa/relay" - ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.programa/bin/programad-remote-current" - wrapper_tmp="$HOME/.programa/bin/.programa-wrapper.tmp.$$" - cat > "$wrapper_tmp" <<'CMUXWRAPPER' - \(remoteCLIWrapperScript()) - CMUXWRAPPER - chmod 755 "$wrapper_tmp" - mv -f "$wrapper_tmp" "$HOME/.programa/bin/programa" - """ - } - - static func remoteRelayMetadataInstallScript( - daemonRemotePath: String, - relayPort: Int, - relayID: String, - relayToken: String - ) -> String { - let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) - let authPayload = """ - {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} - """ - return """ - umask 077 - mkdir -p "$HOME/.programa" "$HOME/.programa/relay" - chmod 700 "$HOME/.programa/relay" - \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) - printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.programa/relay/\(relayPort).daemon_path" - cat > "$HOME/.programa/relay/\(relayPort).auth" <<'PROGRAMARELAYAUTH' - \(authPayload) - PROGRAMARELAYAUTH - chmod 600 "$HOME/.programa/relay/\(relayPort).auth" - printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.programa/socket_addr" - """ - } - - static func mapUnameOS(_ raw: String) -> String? { - switch raw.lowercased() { - case "linux": - return "linux" - case "darwin": - return "darwin" - case "freebsd": - return "freebsd" - default: - return nil - } - } - - static func mapUnameArch(_ raw: String) -> String? { - switch raw.lowercased() { - case "x86_64", "amd64": - return "amd64" - case "aarch64", "arm64": - return "arm64" - case "armv7l": - return "arm" - default: - return nil - } - } - - static func remoteDaemonVersion() -> String { - let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" - guard allowLocalDaemonBuildFallback(), - let sourceFingerprint = remoteDaemonSourceFingerprint(), - !sourceFingerprint.isEmpty else { - return baseVersion - } - return "\(baseVersion)-dev-\(sourceFingerprint)" - } - - static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() - - static func remoteDaemonSourceFingerprint() -> String? { - cachedRemoteDaemonSourceFingerprint - } - - static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { - guard let repoRoot = findRepoRoot() else { return nil } - let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) - guard let enumerator = fileManager.enumerator( - at: daemonRoot, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { - return nil - } - - var relativePaths: [String] = [] - for case let fileURL as URL in enumerator { - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), - resourceValues.isRegularFile == true else { - continue - } - - let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") - if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { - relativePaths.append(relativePath) - } - } - - guard !relativePaths.isEmpty else { return nil } - - let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in - let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) - guard let fileData = try? Data(contentsOf: fileURL) else { return } - partialResult.append(Data(relativePath.utf8)) - partialResult.append(0) - partialResult.append(fileData) - partialResult.append(0) - }) - let hex = digest.map { String(format: "%02x", $0) }.joined() - return String(hex.prefix(12)) - } - - static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { - ".programa/bin/programad-remote/\(version)/\(goOS)-\(goArch)/programad-remote" - } - - /// Prunes stale `programad-remote` version install directories under - /// `$HOME/.programa/bin/programad-remote/`, keeping the current version - /// (just confirmed present by the caller, after a successful install) - /// plus the most-recently-used other version directory (audit finding - /// M12: version-scoped installs otherwise accumulate forever on remote - /// hosts, since each app version only probes/uploads its own directory - /// and never touches others). - /// - /// Version strings are `major.minor.patch` where `patch` is a CI run - /// number (e.g. "0.4.9" vs "0.4.100"), so a lexical sort of version - /// directory names would misorder them. There is no existing - /// version-compare helper among this file's POSIX sh script builders, - /// so retention is decided by directory mtime (newest = most recently - /// installed/used) instead of parsing/comparing version strings in - /// shell -- simpler and correct without a numeric-segment parser. - static func remoteDaemonPruneStaleVersionsScript(currentVersion: String) -> String { - let trimmedVersion = currentVersion.trimmingCharacters(in: .whitespacesAndNewlines) - let quotedVersion = RemoteSSHConnectionPolicy.shellSingleQuoted(trimmedVersion) - return """ - programa_daemon_base="$HOME/.programa/bin/programad-remote" - programa_current_version=\(quotedVersion) - if [ -n "$programa_daemon_base" ] && [ -d "$programa_daemon_base" ]; then - programa_keep_other="" - programa_keep_other_mtime=0 - for programa_version_dir in "$programa_daemon_base"/*/; do - [ -d "$programa_version_dir" ] || continue - [ -L "${programa_version_dir%/}" ] && continue - programa_version_name="$(basename "$programa_version_dir")" - [ "$programa_version_name" = "$programa_current_version" ] && continue - programa_dir_mtime="$(stat -f '%m' "$programa_version_dir" 2>/dev/null || stat -c '%Y' "$programa_version_dir" 2>/dev/null || echo 0)" - case "$programa_dir_mtime" in - ''|*[!0-9]*) programa_dir_mtime=0 ;; - esac - if [ "$programa_dir_mtime" -gt "$programa_keep_other_mtime" ]; then - programa_keep_other_mtime="$programa_dir_mtime" - programa_keep_other="$programa_version_name" - fi - done - for programa_version_dir in "$programa_daemon_base"/*/; do - [ -d "$programa_version_dir" ] || continue - [ -L "${programa_version_dir%/}" ] && continue - programa_version_name="$(basename "$programa_version_dir")" - [ "$programa_version_name" = "$programa_current_version" ] && continue - if [ -n "$programa_keep_other" ] && [ "$programa_version_name" = "$programa_keep_other" ]; then - continue - fi - case "$programa_version_dir" in - "$programa_daemon_base"/*) - rm -rf -- "$programa_version_dir" || true - ;; - esac - done - fi - """ - } - - static func orphanedCMUXRemoteSSHPIDs( - psOutput: String, - destination: String, - relayPort: Int? = nil - ) -> [Int] { - let trimmedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDestination.isEmpty else { return [] } - - return psOutput - .split(separator: "\n", omittingEmptySubsequences: false) - .compactMap { line -> Int? in - guard let parsed = parsePSLine(line) else { return nil } - guard parsed.ppid == 1 else { return nil } - guard isOrphanedCMUXRemoteSSHCommand( - parsed.command, - destination: trimmedDestination, - relayPort: relayPort - ) else { - return nil - } - return parsed.pid - } - .sorted() - } - - static func killOrphanedRemoteSSHProcesses(destination: String, relayPort: Int? = nil) { - guard let output = captureCommandStandardOutput( - executablePath: "/bin/ps", - arguments: ["-axo", "pid=,ppid=,command="] - ) else { - return - } - - for pid in orphanedCMUXRemoteSSHPIDs( - psOutput: output, - destination: destination, - relayPort: relayPort - ) { - _ = Darwin.kill(pid_t(pid), SIGTERM) - } - } - - static func captureCommandStandardOutput( - executablePath: String, - arguments: [String] - ) -> String? { - let process = Process() - let stdoutPipe = Pipe() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.standardOutput = stdoutPipe - process.standardError = FileHandle.nullDevice - - do { - try process.run() - let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0, - let output = String(data: outputData, encoding: .utf8), - !output.isEmpty else { - return nil - } - return output - } catch { - // Best effort cleanup only. - return nil - } - } - - static func parsePSLine(_ line: Substring) -> (pid: Int, ppid: Int, command: String)? { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let scanner = Scanner(string: trimmed) - var pidValue: Int = 0 - var ppidValue: Int = 0 - guard scanner.scanInt(&pidValue), scanner.scanInt(&ppidValue) else { - return nil - } - - let commandStart = scanner.currentIndex - let command = String(trimmed[commandStart...]).trimmingCharacters(in: .whitespacesAndNewlines) - guard !command.isEmpty else { return nil } - return (pidValue, ppidValue, command) - } - - static func isOrphanedCMUXRemoteSSHCommand( - _ command: String, - destination: String, - relayPort: Int? - ) -> Bool { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - guard trimmed.hasPrefix("/usr/bin/ssh ") || trimmed.hasPrefix("ssh ") else { return false } - guard commandContainsDestination(trimmed, destination: destination) else { return false } - - if let relayPort { - return trimmed.contains(" -N ") - && trimmed.contains(" -R 127.0.0.1:\(relayPort):127.0.0.1:") - } - - if trimmed.contains(" -N ") && trimmed.contains(" -R 127.0.0.1:") { - return true - } - if trimmed.contains("programad-remote") && trimmed.contains(" serve --stdio") { - return true - } - return false - } - - static func commandContainsDestination(_ command: String, destination: String) -> Bool { - guard !destination.isEmpty else { return false } - let escaped = NSRegularExpression.escapedPattern(for: destination) - guard let regex = try? NSRegularExpression( - pattern: "(^|[\\s'\\\"])\(escaped)($|[\\s'\\\"])", - options: [] - ) else { - return command.contains(destination) - } - let range = NSRange(command.startIndex..<command.endIndex, in: command) - return regex.firstMatch(in: command, options: [], range: range) != nil - } - - static func executableSearchPaths( - environment: [String: String] = ProcessInfo.processInfo.environment, - pathHelperOutput: String? = nil - ) -> [String] { - var ordered: [String] = [] - var seen: Set<String> = [] - - func appendSearchPath(_ rawPath: String?) { - guard let rawPath else { return } - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard seen.insert(trimmed).inserted else { return } - ordered.append(trimmed) - } - - if let path = environment["PATH"] { - for component in path.split(separator: ":") { - appendSearchPath(String(component)) - } - } - - if let home = environment["HOME"], !home.isEmpty { - appendSearchPath((home as NSString).appendingPathComponent(".local/bin")) - appendSearchPath((home as NSString).appendingPathComponent("go/bin")) - appendSearchPath((home as NSString).appendingPathComponent("bin")) - } - - let helperOutput = pathHelperOutput ?? pathHelperShellOutput() - for component in parsePathHelperPaths(helperOutput) { - appendSearchPath(component) - } - - for component in [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin", - "/usr/bin", - "/bin", - "/usr/sbin", - "/sbin", - ] { - appendSearchPath(component) - } - - return ordered - } - - static func parsePathHelperPaths(_ output: String) -> [String] { - for fragment in output.split(whereSeparator: { $0 == "\n" || $0 == ";" }) { - let trimmed = fragment.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("PATH=\"") else { continue } - let suffix = trimmed.dropFirst("PATH=\"".count) - guard let closingQuote = suffix.firstIndex(of: "\"") else { return [] } - return suffix[..<closingQuote] - .split(separator: ":") - .map(String.init) - } - return [] - } - - static func pathHelperShellOutput() -> String { - let executable = "/usr/libexec/path_helper" - guard FileManager.default.isExecutableFile(atPath: executable) else { return "" } - - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = ["-s"] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - } catch { - return "" - } - - process.waitUntilExit() - guard process.terminationStatus == 0 else { return "" } - let data = stdout.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } - - static func which(_ executable: String) -> String? { - for component in executableSearchPaths() { - let candidate = (component as NSString).appendingPathComponent(executable) - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - static func findRepoRoot() -> URL? { - var candidates: [URL] = [] - let compileTimeRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // Sources - .deletingLastPathComponent() // repo root - candidates.append(compileTimeRoot) - let environment = ProcessInfo.processInfo.environment - if let envRoot = environment["PROGRAMA_REMOTE_DAEMON_SOURCE_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - if let envRoot = environment["PROGRAMATERM_REPO_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) - if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { - candidates.append(executable) - candidates.append(executable.deletingLastPathComponent()) - candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) - } - - let fm = FileManager.default - for base in candidates { - var cursor = base.standardizedFileURL - for _ in 0..<10 { - let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path - if fm.fileExists(atPath: marker) { - return cursor - } - let parent = cursor.deletingLastPathComponent() - if parent.path == cursor.path { - break - } - cursor = parent - } - } - return nil - } - - static func bestErrorLine(stderr: String, stdout: String = "") -> String? { - if let stderrLine = meaningfulErrorLine(in: stderr) { - return stderrLine - } - if let stdoutLine = meaningfulErrorLine(in: stdout) { - return stdoutLine - } - return nil - } - - static func reverseRelayStartupFailureDetail( - process: Process, - stderrPipe: Pipe, - gracePeriod: TimeInterval = reverseRelayStartupGracePeriod - ) -> String? { - if process.isRunning { - let originalTerminationHandler = process.terminationHandler - let exitSemaphore = DispatchSemaphore(value: 0) - process.terminationHandler = { terminated in - originalTerminationHandler?(terminated) - exitSemaphore.signal() - } - if !process.isRunning { - exitSemaphore.signal() - } - guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { - return nil - } - } - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" - } - - static func meaningfulErrorLine(in text: String) -> String? { - let lines = text - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - for line in lines.reversed() where !isNoiseLine(line) { - return line - } - return lines.last - } - - static func isNoiseLine(_ line: String) -> Bool { - let lowered = line.lowercased() - if lowered.hasPrefix("warning: permanently added") { return true } - if lowered.hasPrefix("debug") { return true } - if lowered.hasPrefix("transferred:") { return true } - if lowered.hasPrefix("openbsd_") { return true } - if lowered.contains("pseudo-terminal will not be allocated") { return true } - return false - } - - static func retrySuffix(retry: Int, delay: TimeInterval) -> String { - let seconds = max(1, Int(delay.rounded())) - return " (retry \(retry) in \(seconds)s)" - } - - static func retryDelay(baseDelay: TimeInterval, retry: Int) -> TimeInterval { - let exponent = Double(max(0, retry - 1)) - return min(baseDelay * pow(2.0, exponent), 60.0) - } - - static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { - let lowered = detail.lowercased() - return lowered.contains("remote daemon transport failed") - || lowered.contains("daemon transport closed stdout") - || lowered.contains("daemon transport exited") - || lowered.contains("daemon transport is not connected") - || lowered.contains("daemon transport stopped") - } - -} diff --git a/Sources/WorkspaceSidebarModels.swift b/Sources/WorkspaceSidebarModels.swift index a3d5a246..9fe80629 100644 --- a/Sources/WorkspaceSidebarModels.swift +++ b/Sources/WorkspaceSidebarModels.swift @@ -241,83 +241,6 @@ enum SidebarBranchOrdering { } } - private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { - guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } - let standardized = NSString(string: normalized).standardizingPath - if standardized == "/root" || standardized.hasPrefix("/root/") { - return "/root" - } - - let components = NSString(string: standardized).pathComponents - if components.count >= 3, components[0] == "/", components[1] == "Users" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 3, components[0] == "/", components[1] == "home" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { - return NSString.path(withComponents: Array(components.prefix(4))) - } - - return nil - } - - private static func inferredHomeDirectory( - matchingTildeDirectory tildeDirectory: String, - absoluteDirectory: String - ) -> String? { - guard let relativePath = relativePathFromTilde(tildeDirectory), - let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } - let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath - let homeDirectory: String - if relativePath.isEmpty { - homeDirectory = standardizedAbsolute - } else { - let suffix = "/" + relativePath - guard standardizedAbsolute.hasSuffix(suffix) else { return nil } - homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) - } - - guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } - return homeDirectory - } - - static func inferredRemoteHomeDirectory( - from directories: [String], - fallbackDirectory: String? - ) -> String? { - let candidates = directories + [fallbackDirectory].compactMap { $0 } - let tildeDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), - relativePathFromTilde(normalized) != nil else { return nil } - return normalized - } - let absoluteDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } - return NSString(string: normalized).standardizingPath - } - - let inferredHomes = Set( - tildeDirectories.flatMap { tildeDirectory in - absoluteDirectories.compactMap { absoluteDirectory in - inferredHomeDirectory( - matchingTildeDirectory: tildeDirectory, - absoluteDirectory: absoluteDirectory - ) - } - } - ) - - if inferredHomes.count == 1 { - return inferredHomes.first - } - if !inferredHomes.isEmpty { - return nil - } - - return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first - } - private static func expandedTildePath( _ directory: String, homeDirectoryForTildeExpansion: String? diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e69de29b..00000000 diff --git a/daemon/remote/README.md b/daemon/remote/README.md deleted file mode 100644 index 32d54254..00000000 --- a/daemon/remote/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# programad-remote (Go) - -Go remote daemon for `programa ssh` bootstrap, capability negotiation, and remote proxy RPC. It is not in the terminal keystroke hot path. - -## Commands - -1. `programad-remote version` -2. `programad-remote serve --stdio` -3. `programad-remote cli <command> [args...]` — relay programa commands to the local app over the reverse SSH forward - -When invoked as `programa` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. - -## RPC methods (newline-delimited JSON over stdio) - -1. `hello` -2. `ping` -3. `proxy.open` -4. `proxy.close` -5. `proxy.write` -6. `proxy.stream.subscribe` -7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events -8. `session.open` -9. `session.close` -10. `session.attach` -11. `session.resize` -12. `session.detach` -13. `session.status` - -Current integration in programa: -1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. -2. Client sends `hello` before enabling remote proxy transport. -3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. -4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). - -`workspace.remote.configure` contract notes: -1. `port` / `local_proxy_port` accept integer values and numeric strings; explicit `null` clears each field. -2. Out-of-range values and invalid types return `invalid_params`. -3. `local_proxy_port` is an internal deterministic test hook used by bind-conflict regressions. -4. SSH option precedence checks are case-insensitive; user overrides for `StrictHostKeyChecking` and control-socket keys prevent default injection. - -## Distribution - -Release builds publish prebuilt `programad-remote` binaries on GitHub Releases for: -1. `darwin/arm64` -2. `darwin/amd64` -3. `linux/arm64` -4. `linux/amd64` - -The app embeds a compact manifest in `Info.plist` with: -1. exact release asset URLs -2. pinned SHA-256 digests -3. release tag and checksums asset URL - -Release apps download and cache the matching binary locally, verify its SHA-256, then upload it to the remote host if needed. Dev builds can opt into a local `go build` fallback with `PROGRAMA_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1`. - -To inspect what a given app build trusts, run: -1. `programa remote-daemon-status` -2. `programa remote-daemon-status --os linux --arch amd64` - -The command prints the exact release asset URL, expected SHA-256, local cache status, and a copy-pasteable `gh attestation verify` command for the selected platform. - -## CLI relay - -The `cli` subcommand (or `programa` wrapper/symlink) connects to the local programa app through an SSH reverse forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. - -Socket discovery order: -1. `--socket <path>` flag -2. `PROGRAMA_SOCKET_PATH` environment variable -3. `~/.programa/socket_addr` file (written by the app after the reverse relay establishes) - -For TCP addresses, the CLI dials once and only refreshes `~/.programa/socket_addr` a single time if the first address was stale. Relay metadata is published only after the reverse forward is ready, so steady-state use does not rely on polling. - -Authenticated relay details: -1. Each SSH workspace gets its own relay ID and relay token. -2. The app runs a local loopback relay server that requires an HMAC-SHA256 challenge-response before forwarding a command to the real local Unix socket. -3. The remote shell never gets direct access to the local app socket. It only gets the reverse-forwarded relay port plus `~/.programa/relay/<port>.auth`, which is written with `0600` permissions and removed when the relay stops. - -Integration additions for the relay path: - -1. Bootstrap installs `~/.programa/bin/programa` wrapper and keeps a default daemon target (`~/.programa/bin/programad-remote-current`). -2. A background `ssh -N -R` process reverse-forwards a TCP port to the authenticated local relay server. The relay address is written to `~/.programa/socket_addr` on the remote. -3. Relay startup writes `~/.programa/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local programa instances or versions coexist. -4. Relay startup writes `~/.programa/relay/<port>.auth` with the relay ID and token needed for HMAC authentication. diff --git a/daemon/remote/cmd/programad-remote/agent_launch.go b/daemon/remote/cmd/programad-remote/agent_launch.go deleted file mode 100644 index 9df0b7b0..00000000 --- a/daemon/remote/cmd/programad-remote/agent_launch.go +++ /dev/null @@ -1,1052 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "net" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" - "time" -) - -const claudeNodeOptionsRestoreModuleScript = `const hadOriginalNodeOptions = process.env.PROGRAMA_ORIGINAL_NODE_OPTIONS_PRESENT === "1"; -if (hadOriginalNodeOptions) { - process.env.NODE_OPTIONS = process.env.PROGRAMA_ORIGINAL_NODE_OPTIONS ?? ""; -} else { - delete process.env.NODE_OPTIONS; -} -delete process.env.PROGRAMA_ORIGINAL_NODE_OPTIONS; -delete process.env.PROGRAMA_ORIGINAL_NODE_OPTIONS_PRESENT; -` - -// agentRelayConfig captures everything that differs between the four agent -// wrapper commands (claude-teams, omo, omx, omc). All four follow the same -// shape: create a shim dir, resolve the real executable, gather focused -// terminal context, configure the shared environment, then exec into the -// tool. runAgentRelay implements that shape once; each run*Relay function -// below is a thin config constructor. -type agentRelayConfig struct { - // cmdLabel names the tool in "programa <cmdLabel>: ..." messages. - cmdLabel string - // execName is the executable to search for in PATH (e.g. "claude"). - execName string - // notFoundHint is appended after "<execName> not found in PATH\n". - // Empty for claude-teams, which offers no install hint. - notFoundHint string - // checkNotFoundEarly aborts immediately once execName can't be - // resolved, before preLaunch/focused-context/env setup. omo, omx, and - // omc all check early. claude-teams instead defers the check until - // just before exec (after focused-context lookup, env configuration, - // and NODE_OPTIONS setup have already run) -- this ordering - // difference has no observable effect since the process exits either - // way, but it is preserved here rather than normalized because it - // reflects the original code path rather than a spec requirement. - checkNotFoundEarly bool - - // createShimDir creates (or reuses) the shim directory for this tool. - createShimDir func() (string, error) - - // preLaunch runs after the executable is resolved (and, for - // checkNotFoundEarly tools, after that check) but before focused-context - // lookup. Only omo uses this, for oh-my-opencode plugin setup. - preLaunch func(originalPath string) error - - tmuxPathPrefix string - cmuxBinEnvVar string - termEnvVar string - extraEnv map[string]string - - // postEnvSetup runs after configureAgentEnvironment. claude-teams and - // omc both use it to configure the NODE_OPTIONS restore module (both - // wrap Claude Code) -- but claude-teams silently ignores setup - // failure while omc prints a warning. That asymmetry exists in the - // original code and is preserved rather than normalized. - postEnvSetup func() - - // buildLaunchArgs adapts the raw CLI args before exec. nil means the - // args are passed through unchanged (omx, omc). claude-teams injects - // --teammate-mode; omo injects a default --port and sets OPENCODE_PORT. - buildLaunchArgs func(args []string) []string - - // directExec execs execPath directly via syscall.Exec (claude-teams: - // claude is a native binary). When false, launch goes through - // resolveNodeScriptExec (omo/omx/omc wrap node/bun scripts). - directExec bool -} - -// runAgentRelay implements the shared shape of `programa claude-teams`, -// `programa omo`, `programa omx`, and `programa omc` on the remote side: -// create shim scripts, resolve the real executable, get the focused -// context via system.identify, configure environment variables, and -// exec into the tool. -func runAgentRelay(socketPath string, args []string, refreshAddr func() string, cfg agentRelayConfig) int { - rc := &rpcContext{socketPath: socketPath, refreshAddr: refreshAddr} - - shimDir, err := cfg.createShimDir() - if err != nil { - fmt.Fprintf(os.Stderr, "programa %s: failed to create shim directory: %v\n", cfg.cmdLabel, err) - return 1 - } - - // Resolve the agent executable BEFORE modifying PATH (so the shim - // directory doesn't shadow anything). Matches the Swift CLI behavior. - originalPath := os.Getenv("PATH") - execPath := findExecutableInPath(cfg.execName, originalPath, shimDir) - - if cfg.checkNotFoundEarly && execPath == "" { - fmt.Fprintf(os.Stderr, "programa %s: %s not found in PATH\n%s", cfg.cmdLabel, cfg.execName, cfg.notFoundHint) - return 1 - } - - if cfg.preLaunch != nil { - if err := cfg.preLaunch(originalPath); err != nil { - fmt.Fprintf(os.Stderr, "programa %s: %v\n", cfg.cmdLabel, err) - return 1 - } - } - - focused := getFocusedContext(rc) - - configureAgentEnvironment(agentConfig{ - shimDir: shimDir, - socketPath: socketPath, - focused: focused, - tmuxPathPrefix: cfg.tmuxPathPrefix, - cmuxBinEnvVar: cfg.cmuxBinEnvVar, - termEnvVar: cfg.termEnvVar, - extraEnv: cfg.extraEnv, - }) - - if cfg.postEnvSetup != nil { - cfg.postEnvSetup() - } - - launchArgs := args - if cfg.buildLaunchArgs != nil { - launchArgs = cfg.buildLaunchArgs(args) - } - - if !cfg.checkNotFoundEarly && execPath == "" { - fmt.Fprintf(os.Stderr, "programa %s: %s not found in PATH\n%s", cfg.cmdLabel, cfg.execName, cfg.notFoundHint) - return 1 - } - - var launchPath string - var launchArgv []string - if cfg.directExec { - launchPath = execPath - launchArgv = append([]string{execPath}, launchArgs...) - } else { - launchPath, launchArgv = resolveNodeScriptExec(execPath, launchArgs, originalPath, shimDir) - } - - execErr := syscall.Exec(launchPath, launchArgv, os.Environ()) - fmt.Fprintf(os.Stderr, "programa %s: exec failed: %v\n", cfg.cmdLabel, execErr) - return 1 -} - -// omoLaunchArgs implements omo's --port default. Explicit caller choices are -// preserved; otherwise it prefers a bindable OPENCODE_PORT, then 4096, then an -// ephemeral loopback port. -func omoLaunchArgs(args []string) []string { - for _, arg := range args { - if arg == "--port" || strings.HasPrefix(arg, "--port=") { - return args - } - } - - selectedPort := 0 - if rawPort := strings.TrimSpace(os.Getenv("OPENCODE_PORT")); rawPort != "" { - if port, err := strconv.Atoi(rawPort); err == nil && port > 0 && port <= 65535 { - if bindablePort, ok := omoBindableLoopbackPort(port); ok { - selectedPort = bindablePort - } - } - } - if selectedPort == 0 { - if bindablePort, ok := omoBindableLoopbackPort(4096); ok { - selectedPort = bindablePort - } - } - if selectedPort == 0 { - if bindablePort, ok := omoBindableLoopbackPort(0); ok { - selectedPort = bindablePort - } - } - if selectedPort == 0 { - // Match the local CLI's last-resort behavior when the bind probe itself - // is unavailable; OpenCode will surface the actual bind failure. - selectedPort = 4096 - } - - port := strconv.Itoa(selectedPort) - os.Setenv("OPENCODE_PORT", port) - return append([]string{"--port", port}, args...) -} - -func omoBindableLoopbackPort(port int) (int, bool) { - listener, err := net.ListenTCP("tcp4", &net.TCPAddr{ - IP: net.IPv4(127, 0, 0, 1), - Port: port, - }) - if err != nil { - return 0, false - } - defer listener.Close() - return listener.Addr().(*net.TCPAddr).Port, true -} - -// runClaudeTeamsRelay implements `programa claude-teams` on the remote side. -func runClaudeTeamsRelay(socketPath string, args []string, refreshAddr func() string) int { - return runAgentRelay(socketPath, args, refreshAddr, agentRelayConfig{ - cmdLabel: "claude-teams", - execName: "claude", - createShimDir: func() (string, error) { - return createTmuxShimDir("claude-teams-bin", claudeTeamsShimScript) - }, - tmuxPathPrefix: "programa-claude-teams", - cmuxBinEnvVar: "PROGRAMA_CLAUDE_TEAMS_PROGRAMA_BIN", - termEnvVar: "PROGRAMA_CLAUDE_TEAMS_TERM", - extraEnv: map[string]string{ - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - }, - postEnvSetup: func() { - if restoreModulePath, err := ensureClaudeNodeOptionsRestoreModule(); err == nil { - configureClaudeNodeOptions(restoreModulePath) - } - }, - buildLaunchArgs: claudeTeamsLaunchArgs, - directExec: true, - }) -} - -// runOMORelay implements `programa omo` on the remote side. -func runOMORelay(socketPath string, args []string, refreshAddr func() string) int { - return runAgentRelay(socketPath, args, refreshAddr, agentRelayConfig{ - cmdLabel: "omo", - execName: "opencode", - notFoundHint: "Install it first:\n npm install -g opencode-ai\n # or\n bun install -g opencode-ai\n", - checkNotFoundEarly: true, - createShimDir: createOMOShimDir, - preLaunch: func(originalPath string) error { - if err := omoEnsurePlugin(originalPath); err != nil { - return fmt.Errorf("plugin setup: %w", err) - } - return nil - }, - tmuxPathPrefix: "programa-omo", - cmuxBinEnvVar: "PROGRAMA_OMO_PROGRAMA_BIN", - termEnvVar: "PROGRAMA_OMO_TERM", - extraEnv: map[string]string{}, - buildLaunchArgs: omoLaunchArgs, - }) -} - -// runOMXRelay implements `programa omx` on the remote side. -func runOMXRelay(socketPath string, args []string, refreshAddr func() string) int { - return runAgentRelay(socketPath, args, refreshAddr, agentRelayConfig{ - cmdLabel: "omx", - execName: "omx", - notFoundHint: "Install it first:\n npm install -g oh-my-codex\n", - checkNotFoundEarly: true, - createShimDir: func() (string, error) { - return createTmuxShimDir("omx-bin", omxShimScript) - }, - tmuxPathPrefix: "programa-omx", - cmuxBinEnvVar: "PROGRAMA_OMX_PROGRAMA_BIN", - termEnvVar: "PROGRAMA_OMX_TERM", - extraEnv: map[string]string{}, - }) -} - -// runOMCRelay implements `programa omc` on the remote side. -func runOMCRelay(socketPath string, args []string, refreshAddr func() string) int { - return runAgentRelay(socketPath, args, refreshAddr, agentRelayConfig{ - cmdLabel: "omc", - execName: "omc", - notFoundHint: "Install it first:\n npm install -g oh-my-claude-sisyphus\n", - checkNotFoundEarly: true, - createShimDir: func() (string, error) { - return createTmuxShimDir("omc-bin", omcShimScript) - }, - tmuxPathPrefix: "programa-omc", - cmuxBinEnvVar: "PROGRAMA_OMC_PROGRAMA_BIN", - termEnvVar: "PROGRAMA_OMC_TERM", - extraEnv: map[string]string{}, - // omc wraps Claude Code, so configure NODE_OPTIONS restore module. - postEnvSetup: func() { - if restoreModulePath, err := ensureClaudeNodeOptionsRestoreModule(); err == nil { - configureClaudeNodeOptions(restoreModulePath) - } else { - fmt.Fprintf(os.Stderr, "programa omc: warning: failed to create NODE_OPTIONS restore module: %v\n", err) - } - }, - }) -} - -// --- Shim creation --- - -const claudeTeamsShimScript = `#!/usr/bin/env bash -set -euo pipefail -exec "${PROGRAMA_CLAUDE_TEAMS_PROGRAMA_BIN:-programa}" __tmux-compat "$@" -` - -const omoTmuxShimScript = `#!/usr/bin/env bash -set -euo pipefail -# Only match -V/-v as the first arg (top-level tmux flag). -# -v inside subcommands (e.g. split-window -v) is a vertical split flag. -case "${1:-}" in - -V|-v) echo "tmux 3.4"; exit 0 ;; -esac -exec "${PROGRAMA_OMO_PROGRAMA_BIN:-programa}" __tmux-compat "$@" -` - -const omxShimScript = `#!/usr/bin/env bash -set -euo pipefail -case "${1:-}" in - -V|-v) echo "tmux 3.4"; exit 0 ;; -esac -exec "${PROGRAMA_OMX_PROGRAMA_BIN:-programa}" __tmux-compat "$@" -` - -const omcShimScript = `#!/usr/bin/env bash -set -euo pipefail -case "${1:-}" in - -V|-v) echo "tmux 3.4"; exit 0 ;; -esac -exec "${PROGRAMA_OMC_PROGRAMA_BIN:-programa}" __tmux-compat "$@" -` - -const omoNotifierShimScript = `#!/usr/bin/env bash -# Intercept terminal-notifier calls and route through programa notify. -TITLE="" BODY="" -while [[ $# -gt 0 ]]; do - case "$1" in - -title) TITLE="$2"; shift 2 ;; - -message) BODY="$2"; shift 2 ;; - *) shift ;; - esac -done -exec "${PROGRAMA_OMO_PROGRAMA_BIN:-programa}" notify --title "${TITLE:-OpenCode}" --body "${BODY:-}" -` - -func createTmuxShimDir(dirName string, tmuxScript string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - dir := filepath.Join(home, ".programa", dirName) - if err := os.MkdirAll(dir, 0755); err != nil { - return "", err - } - tmuxPath := filepath.Join(dir, "tmux") - if err := writeShimIfChanged(tmuxPath, tmuxScript); err != nil { - return "", err - } - return dir, nil -} - -func createOMOShimDir() (string, error) { - dir, err := createTmuxShimDir("omo-bin", omoTmuxShimScript) - if err != nil { - return "", err - } - notifierPath := filepath.Join(dir, "terminal-notifier") - if err := writeShimIfChanged(notifierPath, omoNotifierShimScript); err != nil { - return "", err - } - return dir, nil -} - -func writeShimIfChanged(path string, content string) error { - existing, err := os.ReadFile(path) - if err == nil && string(existing) == content { - return nil - } - dir := filepath.Dir(path) - tempFile, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") - if err != nil { - return err - } - tempPath := tempFile.Name() - defer os.Remove(tempPath) - if _, err := tempFile.WriteString(content); err != nil { - tempFile.Close() - return err - } - if err := tempFile.Close(); err != nil { - return err - } - if err := os.Chmod(tempPath, 0755); err != nil { - return err - } - if err := os.Rename(tempPath, path); err != nil { - return err - } - return nil -} - -func ensureClaudeNodeOptionsRestoreModule() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - programaDir := filepath.Join(home, ".programa") - runtimeDir := filepath.Join(programaDir, "runtime") - dir := filepath.Join(runtimeDir, "claude-node-options") - for _, component := range []string{programaDir, runtimeDir, dir} { - if err := ensureOwnedPrivateDirectory(component); err != nil { - return "", err - } - } - - restoreModulePath := filepath.Join(dir, "restore-node-options.cjs") - if err := writeOwnedPrivateFileAtomically(restoreModulePath, claudeNodeOptionsRestoreModuleScript); err != nil { - return "", err - } - return restoreModulePath, nil -} - -func ensureOwnedPrivateDirectory(path string) error { - info, err := os.Lstat(path) - if os.IsNotExist(err) { - if err := os.Mkdir(path, 0700); err != nil && !os.IsExist(err) { - return fmt.Errorf("create secure runtime directory: %w", err) - } - info, err = os.Lstat(path) - } - if err != nil { - return fmt.Errorf("inspect secure runtime directory: %w", err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return fmt.Errorf("secure runtime path is not a real directory") - } - - directory, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_DIRECTORY, 0) - if err != nil { - return fmt.Errorf("open secure runtime directory: %w", err) - } - defer directory.Close() - openedInfo, err := directory.Stat() - if err != nil { - return fmt.Errorf("stat secure runtime directory: %w", err) - } - stat, ok := openedInfo.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != uint32(os.Geteuid()) { - return fmt.Errorf("secure runtime directory is not owned by the current user") - } - if err := directory.Chmod(0700); err != nil { - return fmt.Errorf("set secure runtime directory permissions: %w", err) - } - verifiedInfo, err := directory.Stat() - if err != nil || verifiedInfo.Mode().Perm() != 0700 { - return fmt.Errorf("verify secure runtime directory permissions") - } - return nil -} - -func writeOwnedPrivateFileAtomically(path, content string) error { - if info, err := os.Lstat(path); err == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return fmt.Errorf("secure runtime file is not a real file") - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != uint32(os.Geteuid()) { - return fmt.Errorf("secure runtime file is not owned by the current user") - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("inspect secure runtime file: %w", err) - } - - tempFile, err := os.CreateTemp(filepath.Dir(path), ".restore-node-options.tmp-*") - if err != nil { - return fmt.Errorf("create secure runtime file: %w", err) - } - tempPath := tempFile.Name() - defer os.Remove(tempPath) - if err := tempFile.Chmod(0600); err != nil { - tempFile.Close() - return fmt.Errorf("set secure runtime file permissions: %w", err) - } - if _, err := tempFile.WriteString(content); err != nil { - tempFile.Close() - return fmt.Errorf("write secure runtime file: %w", err) - } - if err := tempFile.Sync(); err != nil { - tempFile.Close() - return fmt.Errorf("sync secure runtime file: %w", err) - } - if err := tempFile.Close(); err != nil { - return fmt.Errorf("close secure runtime file: %w", err) - } - if err := os.Rename(tempPath, path); err != nil { - return fmt.Errorf("replace secure runtime file: %w", err) - } - - info, err := os.Lstat(path) - if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { - return fmt.Errorf("verify secure runtime file") - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != uint32(os.Geteuid()) { - return fmt.Errorf("secure runtime file is not owned by the current user") - } - return nil -} - -// --- Focused context --- - -type focusedContext struct { - workspaceId string - windowId string - paneHandle string - // paneId is the canonicalized pane UUID, resolved via tmux_target.go's - // selector matching. Falls back to paneHandle when canonicalization - // fails or times out. Used (not paneHandle) as the source for the fake - // TMUX_PANE token so the id an agent sees matches what tmux-compat's - // format context later emits for the same pane. - paneId string - surfaceId string -} - -func getFocusedContext(rc *rpcContext) *focusedContext { - return getFocusedContextWithTimeout(rc, 5*time.Second) -} - -func getFocusedContextWithTimeout(rc *rpcContext, timeout time.Duration) *focusedContext { - // Use a goroutine with timeout so a slow/stale relay doesn't block agent launch. - type result struct { - payload map[string]any - } - ch := make(chan result, 1) - started := time.Now() - go func() { - payload, err := rc.call("system.identify", nil) - if err != nil { - ch <- result{} - return - } - ch <- result{payload: payload} - }() - - var payload map[string]any - select { - case r := <-ch: - payload = r.payload - case <-time.After(timeout): - return nil - } - - focused, _ := payload["focused"].(map[string]any) - if focused == nil { - return nil - } - ctx := focusedContextFromIdentify(focused) - if ctx == nil { - return nil - } - - remaining := timeout - time.Since(started) - if remaining <= 0 { - return ctx - } - return canonicalizeFocusedContextWithTimeout(rc, focused, ctx, remaining) -} - -func focusedContextFromIdentify(focused map[string]any) *focusedContext { - wsId := stringFromAny(focused["workspace_id"], focused["workspace_ref"]) - paneHandle := stringFromAny(focused["pane_id"], focused["pane_ref"]) - if wsId == "" || paneHandle == "" { - return nil - } - return &focusedContext{ - workspaceId: wsId, - windowId: stringFromAny(focused["window_id"], focused["window_ref"]), - paneHandle: strings.TrimSpace(paneHandle), - paneId: strings.TrimSpace(stringFromAny(focused["pane_uuid"], focused["pane_id"])), - surfaceId: stringFromAny(focused["surface_id"], focused["surface_ref"]), - } -} - -// canonicalizeFocusedContextWithTimeout resolves ctx.paneId to the -// canonical pane UUID in the background, bounded by timeout so a -// slow/stale relay can't block agent launch. Returns the base context -// unchanged if canonicalization doesn't finish in time. -func canonicalizeFocusedContextWithTimeout( - rc *rpcContext, - focused map[string]any, - base *focusedContext, - timeout time.Duration, -) *focusedContext { - type result struct { - focused *focusedContext - } - ch := make(chan result, 1) - go func() { - enriched := *base - canonicalizeFocusedContext(rc, focused, &enriched) - ch <- result{focused: &enriched} - }() - select { - case r := <-ch: - return r.focused - case <-time.After(timeout): - return base - } -} - -func canonicalizeFocusedContext(rc *rpcContext, focused map[string]any, ctx *focusedContext) { - canonicalPaneId := strings.TrimSpace(stringFromAny(focused["pane_uuid"])) - if canonicalWsId, err := tmuxResolveWorkspaceId(rc, ctx.workspaceId); err == nil { - if canonicalPaneId == "" { - if pid := strings.TrimSpace(stringFromAny(focused["pane_id"])); pid != "" { - if resolved, err := tmuxCanonicalPaneId(rc, pid, canonicalWsId); err == nil { - canonicalPaneId = resolved - } - } - } - if canonicalPaneId == "" { - if pid, err := tmuxCanonicalPaneId(rc, ctx.paneHandle, canonicalWsId); err == nil { - canonicalPaneId = pid - } - } - } - if canonicalPaneId == "" { - canonicalPaneId = strings.TrimSpace(stringFromAny(focused["pane_id"])) - } - if canonicalPaneId != "" { - ctx.paneId = strings.TrimSpace(canonicalPaneId) - } -} - -func configureClaudeNodeOptions(restoreModulePath string) { - existing, hadExisting := os.LookupEnv("NODE_OPTIONS") - if hadExisting { - os.Setenv("PROGRAMA_ORIGINAL_NODE_OPTIONS_PRESENT", "1") - os.Setenv("PROGRAMA_ORIGINAL_NODE_OPTIONS", existing) - } else { - os.Setenv("PROGRAMA_ORIGINAL_NODE_OPTIONS_PRESENT", "0") - os.Unsetenv("PROGRAMA_ORIGINAL_NODE_OPTIONS") - } - os.Setenv("NODE_OPTIONS", mergeNodeOptions(existing, restoreModulePath)) -} - -func mergeNodeOptions(existing string, restoreModulePath string) string { - requireFlag := "--require=" + restoreModulePath - const memoryFlag = "--max-old-space-size=4096" - cleaned := cleanedNodeOptions(existing) - if cleaned == "" { - return requireFlag + " " + memoryFlag - } - return requireFlag + " " + memoryFlag + " " + cleaned -} - -func cleanedNodeOptions(existing string) string { - tokens := strings.Fields(existing) - if len(tokens) == 0 { - return "" - } - - filtered := make([]string, 0, len(tokens)) - for i := 0; i < len(tokens); i++ { - token := tokens[i] - if token == "--max-old-space-size" { - if i+1 < len(tokens) { - i++ - } - continue - } - if strings.HasPrefix(token, "--max-old-space-size=") { - continue - } - filtered = append(filtered, token) - } - return strings.Join(filtered, " ") -} - -func stringFromAny(values ...any) string { - for _, v := range values { - if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { - return strings.TrimSpace(s) - } - } - return "" -} - -// --- Environment configuration --- - -type agentConfig struct { - shimDir string - socketPath string - focused *focusedContext - tmuxPathPrefix string - cmuxBinEnvVar string - termEnvVar string - extraEnv map[string]string -} - -func configureAgentEnvironment(cfg agentConfig) { - // Find our own executable path for the shim to call back - selfPath, _ := os.Executable() - if selfPath == "" { - selfPath = "programa" - } - os.Setenv(cfg.cmuxBinEnvVar, selfPath) - - // Prepend shim directory to PATH - currentPath := os.Getenv("PATH") - os.Setenv("PATH", cfg.shimDir+":"+currentPath) - - // Set fake TMUX/TMUX_PANE - fakeTmux := fmt.Sprintf("/tmp/%s/default,0,0", cfg.tmuxPathPrefix) - fakeTmuxPane := "%1" - if cfg.focused != nil { - windowToken := cfg.focused.windowId - if windowToken == "" { - windowToken = cfg.focused.workspaceId - } - paneIdForToken := cfg.focused.paneId - if paneIdForToken == "" { - paneIdForToken = cfg.focused.paneHandle - } - paneToken := tmuxStableNumericId(paneIdForToken) - fakeTmux = fmt.Sprintf("/tmp/%s/%s,%s,%s", - cfg.tmuxPathPrefix, cfg.focused.workspaceId, windowToken, paneToken) - fakeTmuxPane = "%" + paneToken - } - os.Setenv("TMUX", fakeTmux) - os.Setenv("TMUX_PANE", fakeTmuxPane) - - // Terminal settings - fakeTerm := os.Getenv(cfg.termEnvVar) - if fakeTerm == "" { - fakeTerm = "screen-256color" - } - os.Setenv("TERM", fakeTerm) - - // Socket path - os.Setenv("PROGRAMA_SOCKET_PATH", cfg.socketPath) - os.Setenv("PROGRAMA_SOCKET", cfg.socketPath) - - // Unset TERM_PROGRAM so apps don't detect the host terminal and - // override tmux-compatible behavior (e.g. opencode switches to - // light theme when it sees TERM_PROGRAM=ghostty). - os.Unsetenv("TERM_PROGRAM") - - // Preserve COLORTERM for truecolor support in subagent panes. - if os.Getenv("COLORTERM") == "" { - os.Setenv("COLORTERM", "truecolor") - } - - // Set workspace/surface IDs from focused context - if cfg.focused != nil { - os.Setenv("PROGRAMA_WORKSPACE_ID", cfg.focused.workspaceId) - if cfg.focused.surfaceId != "" { - os.Setenv("PROGRAMA_SURFACE_ID", cfg.focused.surfaceId) - } - } - - // Extra environment variables - for k, v := range cfg.extraEnv { - os.Setenv(k, v) - } -} - -// --- oh-my-opencode plugin setup --- - -const omoPluginName = "oh-my-opencode" - -func omoUserConfigDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".config", "opencode") -} - -func omoShadowConfigDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".programa", "omo-config") -} - -func ensureOMOShadowPackageManifest(path string) error { - if info, err := os.Lstat(path); err == nil { - if info.Mode()&os.ModeSymlink != 0 { - if err := os.Remove(path); err != nil { - return err - } - } - } else if !os.IsNotExist(err) { - return err - } - - manifest := map[string]any{ - "dependencies": map[string]string{ - omoPluginName: "latest", - }, - "name": "programa-omo-shadow", - "private": true, - } - data, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - return os.WriteFile(path, data, 0644) -} - -// omoEnsurePlugin creates a shadow config directory that layers the -// oh-my-opencode plugin on top of the user's opencode config, installs -// the plugin if needed, and sets OPENCODE_CONFIG_DIR. -func omoEnsurePlugin(searchPath string) error { - userDir := omoUserConfigDir() - shadowDir := omoShadowConfigDir() - - if err := os.MkdirAll(shadowDir, 0755); err != nil { - return fmt.Errorf("create shadow config dir: %w", err) - } - - // Read user's opencode.json, add the plugin, write to shadow dir - userJsonPath := filepath.Join(userDir, "opencode.json") - shadowJsonPath := filepath.Join(shadowDir, "opencode.json") - - var config map[string]any - if data, err := os.ReadFile(userJsonPath); err == nil { - if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("invalid opencode.json: fix the JSON syntax and retry") - } - } else { - config = map[string]any{} - } - - // Add oh-my-opencode to the plugins list - var plugins []string - if raw, ok := config["plugin"].([]any); ok { - for _, p := range raw { - if s, ok := p.(string); ok { - plugins = append(plugins, s) - } - } - } - alreadyPresent := false - for _, p := range plugins { - if p == omoPluginName || strings.HasPrefix(p, omoPluginName+"@") { - alreadyPresent = true - break - } - } - if !alreadyPresent { - plugins = append(plugins, omoPluginName) - } - config["plugin"] = plugins - - output, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - if err := os.WriteFile(shadowJsonPath, output, 0644); err != nil { - return err - } - - // Symlink node_modules from user config dir - shadowNodeModules := filepath.Join(shadowDir, "node_modules") - userNodeModules := filepath.Join(userDir, "node_modules") - if dirExists(userNodeModules) { - target, _ := os.Readlink(shadowNodeModules) - if target != userNodeModules { - os.Remove(shadowNodeModules) - os.Symlink(userNodeModules, shadowNodeModules) - } - } - - // The shadow config owns its package metadata so stale or yanked pins in - // the user's package.json/bun.lock cannot poison plugin installation. - shadowPackagePath := filepath.Join(shadowDir, "package.json") - if err := ensureOMOShadowPackageManifest(shadowPackagePath); err != nil { - return fmt.Errorf("write shadow package manifest: %w", err) - } - shadowLockPath := filepath.Join(shadowDir, "bun.lock") - if info, err := os.Lstat(shadowLockPath); err == nil && info.Mode()&os.ModeSymlink != 0 { - if err := os.Remove(shadowLockPath); err != nil { - return fmt.Errorf("remove shadow lockfile symlink: %w", err) - } - } else if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("inspect shadow lockfile: %w", err) - } - - // Symlink oh-my-opencode config files - for _, filename := range []string{"oh-my-opencode.json", "oh-my-opencode.jsonc"} { - userFile := filepath.Join(userDir, filename) - shadowFile := filepath.Join(shadowDir, filename) - if fileExists(userFile) && !fileExists(shadowFile) { - os.Symlink(userFile, shadowFile) - } - } - - // Install the plugin if not available - pluginPackageDir := filepath.Join(shadowNodeModules, omoPluginName) - if !dirExists(pluginPackageDir) { - // A missing plugin must be installed into the shadow config, not into - // the user's package state through the node_modules compatibility link. - if info, err := os.Lstat(shadowNodeModules); err == nil && info.Mode()&os.ModeSymlink != 0 { - if err := os.Remove(shadowNodeModules); err != nil { - return fmt.Errorf("remove shadow node_modules symlink: %w", err) - } - } else if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("inspect shadow node_modules: %w", err) - } - installDir := shadowDir - os.MkdirAll(installDir, 0755) - - bunPath := findExecutableInPath("bun", searchPath, "") - npmPath := findExecutableInPath("npm", searchPath, "") - if bunPath == "" && npmPath == "" { - return fmt.Errorf("neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") - } - - fmt.Fprintf(os.Stderr, "Installing oh-my-opencode plugin...\n") - var cmd *exec.Cmd - if bunPath != "" { - cmd = exec.Command(bunPath, "add", omoPluginName) - } else { - cmd = exec.Command(npmPath, "install", omoPluginName) - } - cmd.Dir = installDir - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to install oh-my-opencode: %v\nTry manually: npm install -g oh-my-opencode", err) - } - fmt.Fprintf(os.Stderr, "oh-my-opencode plugin installed\n") - } - - // Configure oh-my-opencode.json with tmux settings - omoConfigPath := filepath.Join(shadowDir, "oh-my-opencode.json") - var omoConfig map[string]any - if data, err := os.ReadFile(omoConfigPath); err == nil { - json.Unmarshal(data, &omoConfig) - } - if omoConfig == nil { - // Check if user had one we symlinked - userOmoConfig := filepath.Join(userDir, "oh-my-opencode.json") - if data, err := os.ReadFile(userOmoConfig); err == nil { - json.Unmarshal(data, &omoConfig) - os.Remove(omoConfigPath) // Remove symlink so we can write our own copy - } - } - if omoConfig == nil { - omoConfig = map[string]any{} - } - - tmuxConfig, _ := omoConfig["tmux"].(map[string]any) - if tmuxConfig == nil { - tmuxConfig = map[string]any{} - } - needsWrite := false - if enabled, _ := tmuxConfig["enabled"].(bool); !enabled { - tmuxConfig["enabled"] = true - needsWrite = true - } - if tmuxConfig["main_pane_min_width"] == nil { - tmuxConfig["main_pane_min_width"] = 60 - needsWrite = true - } - if tmuxConfig["agent_pane_min_width"] == nil { - tmuxConfig["agent_pane_min_width"] = 30 - needsWrite = true - } - if tmuxConfig["main_pane_size"] == nil { - tmuxConfig["main_pane_size"] = 50 - needsWrite = true - } - if needsWrite { - omoConfig["tmux"] = tmuxConfig - // Remove symlink if it exists - if target, err := os.Readlink(omoConfigPath); err == nil && target != "" { - os.Remove(omoConfigPath) - } - data, _ := json.MarshalIndent(omoConfig, "", " ") - os.WriteFile(omoConfigPath, data, 0644) - } - - os.Setenv("OPENCODE_CONFIG_DIR", shadowDir) - return nil -} - -func fileExists(path string) bool { - _, err := os.Lstat(path) - return err == nil -} - -func dirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() -} - -// --- Node script resolution --- - -// resolveNodeScriptExec checks if the target binary is a #!/usr/bin/env node -// script. If node isn't in PATH but bun is, it rewrites the exec to use bun -// as the runtime (bun is node-compatible). -func resolveNodeScriptExec(binPath string, args []string, searchPath string, skipDir string) (string, []string) { - if !isNodeScript(binPath) { - return binPath, append([]string{binPath}, args...) - } - - // node in PATH? Use the script directly. - if findExecutableInPath("node", searchPath, skipDir) != "" { - return binPath, append([]string{binPath}, args...) - } - - // Fall back to bun as a node-compatible runtime. - bunPath := findExecutableInPath("bun", searchPath, skipDir) - if bunPath != "" { - return bunPath, append([]string{bunPath, binPath}, args...) - } - - // No node or bun; exec the script directly and let the OS error. - return binPath, append([]string{binPath}, args...) -} - -func isNodeScript(path string) bool { - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - buf := make([]byte, 64) - n, _ := f.Read(buf) - line := string(buf[:n]) - return strings.Contains(line, "/env node") || strings.Contains(line, "/bin/node") -} - -// --- Executable resolution --- - -// findExecutableInPath searches the given PATH string for an executable, -// skipping skipDir (the shim directory). Takes an explicit PATH to ensure -// we search the original PATH before environment modifications. -func findExecutableInPath(name string, pathEnv string, skipDir string) string { - for _, dir := range filepath.SplitList(pathEnv) { - if dir == "" || dir == skipDir { - continue - } - candidate := filepath.Join(dir, name) - if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { - return candidate - } - } - return "" -} - -// --- Claude Teams launch args --- - -func claudeTeamsLaunchArgs(args []string) []string { - // Check if --teammate-mode is already specified - for _, arg := range args { - if arg == "--teammate-mode" || strings.HasPrefix(arg, "--teammate-mode=") { - return args - } - } - return append([]string{"--teammate-mode", "auto"}, args...) -} diff --git a/daemon/remote/cmd/programad-remote/agent_launch_test.go b/daemon/remote/cmd/programad-remote/agent_launch_test.go deleted file mode 100644 index f06ad1c7..00000000 --- a/daemon/remote/cmd/programad-remote/agent_launch_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestOmoEnsurePluginInvalidJSONErrorDoesNotExposeUserPath(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - userDir := filepath.Join(home, ".config", "opencode") - if err := os.MkdirAll(userDir, 0755); err != nil { - t.Fatalf("failed to create user config dir: %v", err) - } - userJSONPath := filepath.Join(userDir, "opencode.json") - if err := os.WriteFile(userJSONPath, []byte("{"), 0644); err != nil { - t.Fatalf("failed to write invalid config: %v", err) - } - - err := omoEnsurePlugin(os.Getenv("PATH")) - if err == nil { - t.Fatal("omoEnsurePlugin returned nil for invalid opencode.json") - } - - msg := err.Error() - if strings.Contains(msg, home) || strings.Contains(msg, userJSONPath) { - t.Fatalf("error %q exposes user config path %q", msg, userJSONPath) - } - if !strings.Contains(msg, "invalid opencode.json") { - t.Fatalf("error = %q, want generic invalid opencode.json message", msg) - } -} - -func TestEnsureClaudeNodeOptionsRestoreModuleUsesPrivateUserStorage(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - path, err := ensureClaudeNodeOptionsRestoreModule() - if err != nil { - t.Fatalf("ensureClaudeNodeOptionsRestoreModule: %v", err) - } - want := filepath.Join(home, ".programa", "runtime", "claude-node-options", "restore-node-options.cjs") - if path != want { - t.Fatalf("restore module path = %q, want %q", path, want) - } - for _, directory := range []string{ - filepath.Join(home, ".programa"), - filepath.Join(home, ".programa", "runtime"), - filepath.Dir(want), - } { - info, statErr := os.Lstat(directory) - if statErr != nil { - t.Fatalf("lstat %q: %v", directory, statErr) - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0700 { - t.Fatalf("directory %q has unsafe mode %v", directory, info.Mode()) - } - } - info, err := os.Lstat(path) - if err != nil { - t.Fatalf("lstat restore module: %v", err) - } - if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0600 { - t.Fatalf("restore module has unsafe mode %v", info.Mode()) - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read restore module: %v", err) - } - if string(data) != claudeNodeOptionsRestoreModuleScript { - t.Fatal("restore module content changed") - } -} - -func TestEnsureClaudeNodeOptionsRestoreModuleRejectsSymlinkedProgramaDirectory(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - foreign := t.TempDir() - if err := os.Symlink(foreign, filepath.Join(home, ".programa")); err != nil { - t.Fatalf("create malicious symlink: %v", err) - } - - if _, err := ensureClaudeNodeOptionsRestoreModule(); err == nil { - t.Fatal("expected symlinked .programa directory to be rejected") - } - if _, err := os.Stat(filepath.Join(foreign, "runtime", "claude-node-options", "restore-node-options.cjs")); !os.IsNotExist(err) { - t.Fatalf("restore module followed symlink into foreign directory: %v", err) - } -} diff --git a/daemon/remote/cmd/programad-remote/cli.go b/daemon/remote/cmd/programad-remote/cli.go deleted file mode 100644 index f493031a..00000000 --- a/daemon/remote/cmd/programad-remote/cli.go +++ /dev/null @@ -1,736 +0,0 @@ -package main - -import ( - "bufio" - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net" - "os" - "path/filepath" - "strings" - "time" -) - -type relayAuthState struct { - RelayID string `json:"relay_id"` - RelayToken string `json:"relay_token"` -} - -// commandSpec describes a single CLI command and how to relay it. -type commandSpec struct { - name string // CLI command name (e.g. "ping", "new-window") - v2Method string // v2 JSON-RPC method name - // flagKeys lists parameter keys this command accepts. - // They are extracted from --key flags and added to params. - flagKeys []string - // noParams means the command takes no parameters at all. - noParams bool - // paramKeyOverrides remaps specific flags for compatibility aliases. - paramKeyOverrides map[string]string - // defaultParams are applied before flags/env fallbacks. - defaultParams map[string]any -} - -var commands = []commandSpec{ - {name: "ping", v2Method: "system.ping", noParams: true}, - {name: "new-window", v2Method: "window.create", noParams: true}, - {name: "current-window", v2Method: "window.current", noParams: true}, - {name: "close-window", v2Method: "window.close", flagKeys: []string{"window"}}, - {name: "focus-window", v2Method: "window.focus", flagKeys: []string{"window"}}, - {name: "list-windows", v2Method: "window.list", noParams: true}, - {name: "capabilities", v2Method: "system.capabilities", noParams: true}, - {name: "list-workspaces", v2Method: "workspace.list", noParams: true}, - {name: "new-workspace", v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}}, - {name: "close-workspace", v2Method: "workspace.close", flagKeys: []string{"workspace"}}, - {name: "select-workspace", v2Method: "workspace.select", flagKeys: []string{"workspace"}}, - {name: "current-workspace", v2Method: "workspace.current", noParams: true}, - {name: "list-panels", v2Method: "surface.list", flagKeys: []string{"workspace"}}, - {name: "focus-panel", v2Method: "surface.focus", flagKeys: []string{"panel", "workspace"}, paramKeyOverrides: map[string]string{"panel": "surface_id"}}, - {name: "list-panes", v2Method: "pane.list", flagKeys: []string{"workspace"}}, - {name: "list-pane-surfaces", v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, - {name: "new-pane", v2Method: "pane.create", flagKeys: []string{"workspace", "direction", "type", "url"}, defaultParams: map[string]any{"direction": "right"}}, - {name: "new-surface", v2Method: "surface.create", flagKeys: []string{"workspace", "pane", "type", "url"}}, - {name: "new-split", v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, - {name: "close-surface", v2Method: "surface.close", flagKeys: []string{"surface"}}, - {name: "send", v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, - {name: "send-key", v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, - {name: "notify", v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, - {name: "refresh-surfaces", v2Method: "surface.refresh", noParams: true}, -} - -var commandIndex map[string]*commandSpec - -func init() { - commandIndex = make(map[string]*commandSpec, len(commands)) - for i := range commands { - commandIndex[commands[i].name] = &commands[i] - } -} - -// runCLI is the entry point for the "cli" subcommand (or busybox "programa" invocation). -func runCLI(args []string) int { - socketPath := os.Getenv("PROGRAMA_SOCKET_PATH") - - // Parse global flags - var jsonOutput bool - var remaining []string - for i := 0; i < len(args); i++ { - switch args[i] { - case "--socket": - if i+1 >= len(args) { - fmt.Fprintln(os.Stderr, "programa: --socket requires a path") - return 2 - } - socketPath = args[i+1] - i++ - case "--json": - jsonOutput = true - case "--help", "-h": - cliUsage() - return 0 - default: - remaining = append(remaining, args[i:]...) - goto doneFlags - } - } -doneFlags: - - if len(remaining) == 0 { - cliUsage() - return 2 - } - cmdName := remaining[0] - cmdArgs := remaining[1:] - if cmdName == "help" { - cliUsage() - return 0 - } - - // refreshAddr is set when the address came from socket_addr file (not env/flag), - // allowing one stale-address refresh if another workspace has replaced socket_addr. - var refreshAddr func() string - if socketPath == "" { - socketPath = readSocketAddrFile() - refreshAddr = readSocketAddrFile - } - if socketPath == "" { - fmt.Fprintln(os.Stderr, "programa: PROGRAMA_SOCKET_PATH not set and --socket not provided") - return 1 - } - - // Special case: "rpc" passthrough - if cmdName == "rpc" { - return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) - } - - // Browser subcommand delegation - if cmdName == "browser" { - return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) - } - - // Agent launch commands - if cmdName == "claude-teams" { - return runClaudeTeamsRelay(socketPath, cmdArgs, refreshAddr) - } - if cmdName == "omo" { - return runOMORelay(socketPath, cmdArgs, refreshAddr) - } - if cmdName == "omx" { - return runOMXRelay(socketPath, cmdArgs, refreshAddr) - } - if cmdName == "omc" { - return runOMCRelay(socketPath, cmdArgs, refreshAddr) - } - - // Tmux compatibility layer (used by agent shims) - if cmdName == "__tmux-compat" { - return runTmuxCompat(socketPath, cmdArgs, refreshAddr) - } - - spec, ok := commandIndex[cmdName] - if !ok { - fmt.Fprintf(os.Stderr, "programa: unknown command %q\n", cmdName) - return 2 - } - - return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr) -} - -// execV2 sends a v2 JSON-RPC request over the socket. -func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { - params := make(map[string]any, len(spec.defaultParams)) - for key, value := range spec.defaultParams { - params[key] = value - } - - if !spec.noParams { - parsed, err := parseFlags(args, spec.flagKeys) - if err != nil { - fmt.Fprintf(os.Stderr, "programa: %v\n", err) - return 2 - } - // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) - for _, key := range spec.flagKeys { - if val, ok := parsed.flags[key]; ok { - paramKey := flagToParamKey(key) - if override, ok := spec.paramKeyOverrides[key]; ok { - paramKey = override - } - params[paramKey] = val - } - } - - // First positional arg is used as initial_command if --command wasn't given - if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { - params["initial_command"] = parsed.positional[0] - } - - applyWorkspaceEnvFallback(params) - applySurfaceEnvFallback(params) - } - - resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) - if err != nil { - fmt.Fprintf(os.Stderr, "programa: %v\n", err) - return 1 - } - - if jsonOutput { - fmt.Println(resp) - } else { - fmt.Println(commandRelayOutput(spec.name, resp)) - } - return 0 -} - -func commandRelayOutput(commandName, resp string) string { - var result map[string]any - if err := json.Unmarshal([]byte(resp), &result); err != nil { - return defaultRelayOutput(resp) - } - - switch commandName { - case "ping": - return "PONG" - case "new-window": - if windowID, _ := result["window_id"].(string); windowID != "" { - return "OK " + windowID - } - return "OK" - case "current-window": - if windowID, _ := result["window_id"].(string); windowID != "" { - return windowID - } - return "OK" - case "close-window", "focus-window": - return "OK" - case "list-windows": - windows, _ := result["windows"].([]any) - if len(windows) == 0 { - return "No windows" - } - lines := make([]string, 0, len(windows)) - for _, rawWindow := range windows { - window, _ := rawWindow.(map[string]any) - selected := " " - if isKey, _ := window["key"].(bool); isKey { - selected = "*" - } - lines = append(lines, fmt.Sprintf( - "%s %v: %v selected_workspace=%v workspaces=%v", - selected, - window["index"], - window["id"], - valueOr(window["selected_workspace_id"], "none"), - window["workspace_count"], - )) - } - return strings.Join(lines, "\n") - default: - return defaultRelayOutput(resp) - } -} - -func valueOr(value any, fallback any) any { - if value == nil { - return fallback - } - return value -} - -// runRPC sends an arbitrary JSON-RPC method with optional JSON params. -func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "programa rpc: requires a method name") - return 2 - } - method := args[0] - var params map[string]any - if len(args) > 1 { - if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { - fmt.Fprintf(os.Stderr, "programa rpc: invalid JSON params: %v\n", err) - return 2 - } - } - - resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) - if err != nil { - fmt.Fprintf(os.Stderr, "programa: %v\n", err) - return 1 - } - fmt.Println(resp) - return 0 -} - -// runBrowserRelay handles "programa browser <subcommand>" by mapping to browser.* v2 methods. -func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { - if len(args) == 0 { - fmt.Fprintln(os.Stderr, "programa browser: requires a subcommand (open, navigate, back, forward, reload, get-url)") - return 2 - } - - sub := args[0] - subArgs := args[1:] - - var method string - var flagKeys []string - var allowPositionalURL bool - var useWorkspaceEnv bool - var useSurfaceEnv bool - switch sub { - case "open", "open-split", "new": - method = "browser.open_split" - flagKeys = []string{"url", "workspace", "surface"} - allowPositionalURL = true - useWorkspaceEnv = true - case "navigate": - method = "browser.navigate" - flagKeys = []string{"url", "surface"} - allowPositionalURL = true - useSurfaceEnv = true - case "back": - method = "browser.back" - flagKeys = []string{"surface"} - useSurfaceEnv = true - case "forward": - method = "browser.forward" - flagKeys = []string{"surface"} - useSurfaceEnv = true - case "reload": - method = "browser.reload" - flagKeys = []string{"surface"} - useSurfaceEnv = true - case "get-url": - method = "browser.url.get" - flagKeys = []string{"surface"} - useSurfaceEnv = true - default: - fmt.Fprintf(os.Stderr, "programa browser: unknown subcommand %q\n", sub) - return 2 - } - - params := make(map[string]any) - parsed, err := parseFlags(subArgs, flagKeys) - if err != nil { - fmt.Fprintf(os.Stderr, "programa browser: %v\n", err) - return 2 - } - for _, key := range flagKeys { - if val, ok := parsed.flags[key]; ok { - paramKey := flagToParamKey(key) - params[paramKey] = val - } - } - if allowPositionalURL { - if _, ok := params["url"]; !ok && len(parsed.positional) > 0 { - params["url"] = strings.Join(parsed.positional, " ") - } - } - if useWorkspaceEnv { - applyWorkspaceEnvFallback(params) - } - if useSurfaceEnv { - applySurfaceEnvFallback(params) - } - - resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) - if err != nil { - fmt.Fprintf(os.Stderr, "programa: %v\n", err) - return 1 - } - if jsonOutput { - fmt.Println(resp) - } else { - fmt.Println(defaultRelayOutput(resp)) - } - return 0 -} - -func applyWorkspaceEnvFallback(params map[string]any) { - if _, ok := params["workspace_id"]; ok { - return - } - if envWs := os.Getenv("PROGRAMA_WORKSPACE_ID"); envWs != "" { - params["workspace_id"] = envWs - } -} - -func applySurfaceEnvFallback(params map[string]any) { - if _, ok := params["surface_id"]; ok { - return - } - if envSf := os.Getenv("PROGRAMA_SURFACE_ID"); envSf != "" { - params["surface_id"] = envSf - } -} - -func defaultRelayOutput(resp string) string { - var result any - if err := json.Unmarshal([]byte(resp), &result); err != nil { - trimmed := strings.TrimSpace(resp) - if trimmed == "" { - return "OK" - } - return trimmed - } - - if relayResultIsEmpty(result) { - return "OK" - } - - switch typed := result.(type) { - case string: - return typed - default: - encoded, err := json.MarshalIndent(typed, "", " ") - if err != nil { - return "OK" - } - return string(encoded) - } -} - -func relayResultIsEmpty(result any) bool { - switch typed := result.(type) { - case nil: - return true - case map[string]any: - return len(typed) == 0 - case []any: - return len(typed) == 0 - case string: - return typed == "" - default: - return false - } -} - -// flagToParamKey maps a CLI flag name to its JSON-RPC param key. -func flagToParamKey(key string) string { - switch key { - case "workspace": - return "workspace_id" - case "surface": - return "surface_id" - case "panel": - return "panel_id" - case "pane": - return "pane_id" - case "window": - return "window_id" - case "command": - return "initial_command" - case "name": - return "title" - case "working-directory": - return "working_directory" - default: - return key - } -} - -// parsedFlags holds the results of flag parsing. -type parsedFlags struct { - flags map[string]string // --key value pairs - positional []string // non-flag arguments -} - -// parseFlags extracts --key value pairs from args for the given allowed keys. -// Non-flag arguments are collected in positional. -func parseFlags(args []string, keys []string) (parsedFlags, error) { - allowed := make(map[string]bool, len(keys)) - for _, k := range keys { - allowed[k] = true - } - - result := parsedFlags{flags: make(map[string]string)} - for i := 0; i < len(args); i++ { - if args[i] == "--" { - result.positional = append(result.positional, args[i+1:]...) - break - } - if !strings.HasPrefix(args[i], "--") { - result.positional = append(result.positional, args[i]) - continue - } - key := strings.TrimPrefix(args[i], "--") - if !allowed[key] { - return parsedFlags{}, fmt.Errorf("unknown flag --%s", key) - } - if i+1 < len(args) { - result.flags[key] = args[i+1] - i++ - } - } - return result, nil -} - -// readSocketAddrFile reads the socket address from ~/.programa/socket_addr as a fallback -// when PROGRAMA_SOCKET_PATH is not set. Written by the programa app after the relay establishes. -func readSocketAddrFile() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - data, err := os.ReadFile(filepath.Join(home, ".programa", "socket_addr")) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -func readRelayAuthFile(socketPath string) *relayAuthState { - if strings.Contains(socketPath, ":") && !strings.HasPrefix(socketPath, "/") { - _, port, err := net.SplitHostPort(socketPath) - if err != nil || port == "" { - return nil - } - home, err := os.UserHomeDir() - if err != nil { - return nil - } - data, err := os.ReadFile(filepath.Join(home, ".programa", "relay", port+".auth")) - if err != nil { - return nil - } - var state relayAuthState - if err := json.Unmarshal(data, &state); err != nil { - return nil - } - if state.RelayID == "" || state.RelayToken == "" { - return nil - } - return &state - } - return nil -} - -func currentRelayAuth(socketPath string) *relayAuthState { - relayID := strings.TrimSpace(os.Getenv("PROGRAMA_RELAY_ID")) - relayToken := strings.TrimSpace(os.Getenv("PROGRAMA_RELAY_TOKEN")) - if relayID != "" && relayToken != "" { - return &relayAuthState{RelayID: relayID, RelayToken: relayToken} - } - return readRelayAuthFile(socketPath) -} - -// dialSocket connects to the programa socket. If addr contains a colon and doesn't -// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. -// For TCP connections, refreshAddr is used only to recover from a stale socket_addr -// rewrite, not to poll for relay readiness. -func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { - if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { - conn, connectedAddr, err := dialTCP(addr) - if err != nil && refreshAddr != nil && isConnectionRefused(err) { - if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr { - addr = refreshedAddr - conn, connectedAddr, err = dialTCP(addr) - } - } - if err != nil { - return nil, err - } - if auth := currentRelayAuth(connectedAddr); auth != nil { - if err := authenticateRelayConn(conn, auth); err != nil { - conn.Close() - return nil, err - } - } - return conn, nil - } - return net.Dial("unix", addr) -} - -func dialTCP(addr string) (net.Conn, string, error) { - conn, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err != nil { - return nil, addr, err - } - setTCPNoDelay(conn) - return conn, addr, nil -} - -func isConnectionRefused(err error) bool { - if opErr, ok := err.(*net.OpError); ok { - return strings.Contains(opErr.Err.Error(), "connection refused") - } - return strings.Contains(err.Error(), "connection refused") -} - -func authenticateRelayConn(conn net.Conn, auth *relayAuthState) error { - reader := bufio.NewReader(conn) - _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) - - var challenge struct { - Protocol string `json:"protocol"` - Version int `json:"version"` - RelayID string `json:"relay_id"` - Nonce string `json:"nonce"` - } - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read relay auth challenge: %w", err) - } - if err := json.Unmarshal([]byte(line), &challenge); err != nil { - return fmt.Errorf("invalid relay auth challenge") - } - if challenge.Protocol != "programa-relay-auth" || challenge.Version != 1 || challenge.RelayID != auth.RelayID || challenge.Nonce == "" { - return fmt.Errorf("relay auth challenge mismatch") - } - - tokenBytes, err := hex.DecodeString(auth.RelayToken) - if err != nil { - return fmt.Errorf("invalid relay auth token") - } - mac := computeRelayMAC(tokenBytes, auth.RelayID, challenge.Nonce, challenge.Version) - payload, err := json.Marshal(map[string]any{ - "relay_id": auth.RelayID, - "mac": hex.EncodeToString(mac), - }) - if err != nil { - return fmt.Errorf("failed to encode relay auth response: %w", err) - } - if _, err := conn.Write(append(payload, '\n')); err != nil { - return fmt.Errorf("failed to send relay auth response: %w", err) - } - - line, err = reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read relay auth result: %w", err) - } - var result struct { - OK bool `json:"ok"` - } - if err := json.Unmarshal([]byte(line), &result); err != nil { - return fmt.Errorf("invalid relay auth result") - } - if !result.OK { - return fmt.Errorf("relay auth rejected") - } - _ = conn.SetDeadline(time.Time{}) - return nil -} - -func computeRelayMAC(token []byte, relayID, nonce string, version int) []byte { - mac := hmac.New(sha256.New, token) - _, _ = io.WriteString(mac, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, version)) - return mac.Sum(nil) -} - -// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. -func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) { - conn, err := dialSocket(socketPath, refreshAddr) - if err != nil { - return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) - } - defer conn.Close() - - id := randomHex(8) - req := map[string]any{ - "id": id, - "method": method, - } - if params != nil { - req["params"] = params - } else { - req["params"] = map[string]any{} - } - - payload, err := json.Marshal(req) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) - } - - if _, err := conn.Write(append(payload, '\n')); err != nil { - return "", fmt.Errorf("failed to send request: %w", err) - } - - _ = conn.SetReadDeadline(time.Now().Add(15 * time.Second)) - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - // Parse the response to check for errors - var resp map[string]any - if err := json.Unmarshal([]byte(line), &resp); err != nil { - return strings.TrimRight(line, "\n"), nil - } - - if ok, _ := resp["ok"].(bool); !ok { - if errObj, _ := resp["error"].(map[string]any); errObj != nil { - code, _ := errObj["code"].(string) - msg, _ := errObj["message"].(string) - return "", fmt.Errorf("server error [%s]: %s", code, msg) - } - return "", fmt.Errorf("server returned error response") - } - - // Return the result portion as JSON - if result, ok := resp["result"]; ok { - resultJSON, err := json.Marshal(result) - if err != nil { - return "", fmt.Errorf("failed to marshal result: %w", err) - } - return string(resultJSON), nil - } - - return "{}", nil -} - -func randomHex(n int) string { - b := make([]byte, n) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -func cliUsage() { - fmt.Fprintln(os.Stderr, "Usage: programa [--socket <path>] [--json] <command> [args...]") - fmt.Fprintln(os.Stderr, "") - fmt.Fprintln(os.Stderr, "Commands:") - fmt.Fprintln(os.Stderr, " ping Check connectivity") - fmt.Fprintln(os.Stderr, " capabilities List server capabilities") - fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces") - fmt.Fprintln(os.Stderr, " new-window Create a new window") - fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace") - fmt.Fprintln(os.Stderr, " new-surface Create a new surface") - fmt.Fprintln(os.Stderr, " new-split Split an existing surface") - fmt.Fprintln(os.Stderr, " close-surface Close a surface") - fmt.Fprintln(os.Stderr, " close-workspace Close a workspace") - fmt.Fprintln(os.Stderr, " select-workspace Select a workspace") - fmt.Fprintln(os.Stderr, " send Send text to a surface") - fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") - fmt.Fprintln(os.Stderr, " notify Create a notification") - fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)") - fmt.Fprintln(os.Stderr, " claude-teams [args...] Launch Claude Code in teammate mode") - fmt.Fprintln(os.Stderr, " omo [args...] Launch OpenCode with programa integration") - fmt.Fprintln(os.Stderr, " omx [args...] Launch Oh My Codex with programa integration") - fmt.Fprintln(os.Stderr, " omc [args...] Launch Oh My Claude Code with programa integration") - fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC") -} diff --git a/daemon/remote/cmd/programad-remote/cli_test.go b/daemon/remote/cmd/programad-remote/cli_test.go deleted file mode 100644 index 84ae9543..00000000 --- a/daemon/remote/cmd/programad-remote/cli_test.go +++ /dev/null @@ -1,939 +0,0 @@ -package main - -import ( - "bufio" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - original := os.Stdout - reader, writer, err := os.Pipe() - if err != nil { - t.Fatalf("pipe stdout: %v", err) - } - os.Stdout = writer - defer func() { - os.Stdout = original - }() - - fn() - - if err := writer.Close(); err != nil { - t.Fatalf("close stdout writer: %v", err) - } - output, err := io.ReadAll(reader) - if err != nil { - t.Fatalf("read stdout: %v", err) - } - if err := reader.Close(); err != nil { - t.Fatalf("close stdout reader: %v", err) - } - return string(output) -} - -func makeShortUnixSocketPath(t *testing.T) string { - t.Helper() - dir, err := os.MkdirTemp("/tmp", "programad-") - if err != nil { - t.Fatalf("mkdtemp: %v", err) - } - t.Cleanup(func() { _ = os.RemoveAll(dir) }) - return filepath.Join(dir, "programa.sock") -} - -// startMockV2Socket creates a Unix socket that echoes the received request's method -// back as a successful JSON-RPC response with the method name in the result. -func startMockV2Socket(t *testing.T) string { - t.Helper() - sockPath := makeShortUnixSocketPath(t) - - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - buf := make([]byte, 4096) - n, _ := conn.Read(buf) - if n > 0 { - var req map[string]any - if err := json.Unmarshal(buf[:n], &req); err == nil { - resp := map[string]any{ - "id": req["id"], - "ok": true, - "result": map[string]any{"method": req["method"], "params": req["params"]}, - } - payload, _ := json.Marshal(resp) - conn.Write(append(payload, '\n')) - } else { - conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) - } - } - conn.Close() - } - }() - - return sockPath -} - -func startMockV2SocketWithRequestCapture(t *testing.T) (string, <-chan map[string]any) { - t.Helper() - sockPath := makeShortUnixSocketPath(t) - requests := make(chan map[string]any, 8) - - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - buf := make([]byte, 4096) - n, _ := conn.Read(buf) - if n == 0 { - return - } - var req map[string]any - if err := json.Unmarshal(buf[:n], &req); err != nil { - _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) - return - } - requests <- req - resp := map[string]any{ - "id": req["id"], - "ok": true, - "result": map[string]any{"method": req["method"], "params": req["params"]}, - } - payload, _ := json.Marshal(resp) - _, _ = conn.Write(append(payload, '\n')) - }(conn) - } - }() - - return sockPath, requests -} - -func startMockV2TCPSocketWithResult(t *testing.T, result any) string { - t.Helper() - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to listen on TCP: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - buf := make([]byte, 4096) - n, _ := conn.Read(buf) - if n == 0 { - return - } - var req map[string]any - if err := json.Unmarshal(buf[:n], &req); err != nil { - _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) - return - } - resp := map[string]any{ - "id": req["id"], - "ok": true, - "result": result, - } - payload, _ := json.Marshal(resp) - _, _ = conn.Write(append(payload, '\n')) - }(conn) - } - }() - - return ln.Addr().String() -} - -func startMockAuthenticatedV2TCPSocket(t *testing.T, relayID, relayToken string, result any) string { - t.Helper() - relayTokenBytes := mustHex(t, relayToken) - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to listen on TCP: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - nonce := "testnonce" - challenge, _ := json.Marshal(map[string]any{ - "protocol": "programa-relay-auth", - "version": 1, - "relay_id": relayID, - "nonce": nonce, - }) - _, _ = conn.Write(append(challenge, '\n')) - - reader := bufio.NewReader(conn) - line, err := reader.ReadString('\n') - if err != nil { - return - } - var authResp map[string]any - if err := json.Unmarshal([]byte(line), &authResp); err != nil { - _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) - return - } - macHex, _ := authResp["mac"].(string) - receivedMAC, err := hex.DecodeString(macHex) - if err != nil { - _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) - return - } - - h := hmac.New(sha256.New, relayTokenBytes) - _, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1)) - expectedMAC := h.Sum(nil) - if !hmac.Equal(receivedMAC, expectedMAC) { - _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) - return - } - - _, _ = conn.Write([]byte(`{"ok":true}` + "\n")) - requestLine, err := reader.ReadString('\n') - if err != nil { - return - } - var request map[string]any - if err := json.Unmarshal([]byte(requestLine), &request); err != nil { - return - } - response, _ := json.Marshal(map[string]any{ - "id": request["id"], - "ok": true, - "result": result, - }) - _, _ = conn.Write(append(response, '\n')) - }(conn) - } - }() - - return ln.Addr().String() -} - -func mustHex(t *testing.T, value string) []byte { - t.Helper() - data, err := hex.DecodeString(value) - if err != nil { - t.Fatalf("decode hex: %v", err) - } - return data -} - -func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) { - staleListener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen stale: %v", err) - } - staleAddr := staleListener.Addr().String() - staleListener.Close() - - readyListener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen ready: %v", err) - } - defer readyListener.Close() - - accepted := make(chan struct{}) - go func() { - defer close(accepted) - conn, acceptErr := readyListener.Accept() - if acceptErr != nil { - return - } - conn.Close() - }() - - refreshCalls := 0 - start := time.Now() - conn, err := dialSocket(staleAddr, func() string { - refreshCalls++ - return readyListener.Addr().String() - }) - elapsed := time.Since(start) - if err != nil { - t.Fatalf("dialSocket should refresh to updated address, got: %v", err) - } - conn.Close() - <-accepted - if refreshCalls != 1 { - t.Fatalf("refreshAddr should be called once, got %d", refreshCalls) - } - if elapsed > 500*time.Millisecond { - t.Fatalf("dialSocket should fail over without polling, took %v", elapsed) - } -} - -func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - addr := ln.Addr().String() - ln.Close() - - refreshCalls := 0 - start := time.Now() - _, err = dialSocket(addr, func() string { - refreshCalls++ - return addr - }) - elapsed := time.Since(start) - if err == nil { - t.Fatal("dialSocket should fail when the relay address stays stale") - } - if refreshCalls != 1 { - t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls) - } - if elapsed > 500*time.Millisecond { - t.Fatalf("dialSocket should fail fast without polling, took %v", elapsed) - } -} - -func TestCLIPingUsesSystemPingV2(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - code := runCLI([]string{"--socket", sockPath, "ping"}) - if code != 0 { - t.Fatalf("ping should return 0, got %d", code) - } - select { - case request := <-requests: - if request["method"] != "system.ping" { - t.Fatalf("ping sent method %v, want system.ping", request["method"]) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for ping request") - } -} - -func TestCLIPingV2OverTCP(t *testing.T) { - addr := startMockV2TCPSocketWithResult(t, map[string]any{"pong": true}) - code := runCLI([]string{"--socket", addr, "ping"}) - if code != 0 { - t.Fatalf("ping over TCP should return 0, got %d", code) - } -} - -func TestCLIPingV2OverAuthenticatedTCPWithEnv(t *testing.T) { - relayID := "relay-1" - relayToken := strings.Repeat("a1", 32) - addr := startMockAuthenticatedV2TCPSocket(t, relayID, relayToken, map[string]any{"pong": true}) - t.Setenv("PROGRAMA_RELAY_ID", relayID) - t.Setenv("PROGRAMA_RELAY_TOKEN", relayToken) - - code := runCLI([]string{"--socket", addr, "ping"}) - if code != 0 { - t.Fatalf("ping over authenticated TCP should return 0, got %d", code) - } -} - -func TestCLIPingV2OverAuthenticatedTCPWithRelayFile(t *testing.T) { - relayID := "relay-2" - relayToken := strings.Repeat("b2", 32) - addr := startMockAuthenticatedV2TCPSocket(t, relayID, relayToken, map[string]any{"pong": true}) - _, port, err := net.SplitHostPort(addr) - if err != nil { - t.Fatalf("split host port: %v", err) - } - - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("PROGRAMA_RELAY_ID", "") - t.Setenv("PROGRAMA_RELAY_TOKEN", "") - relayDir := filepath.Join(home, ".programa", "relay") - if err := os.MkdirAll(relayDir, 0o700); err != nil { - t.Fatalf("mkdir relay dir: %v", err) - } - authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken}) - if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil { - t.Fatalf("write auth file: %v", err) - } - - code := runCLI([]string{"--socket", addr, "ping"}) - if code != 0 { - t.Fatalf("ping over authenticated TCP file relay should return 0, got %d", code) - } -} - -func TestDialSocketDetection(t *testing.T) { - // Unix socket paths should attempt Unix dial - for _, path := range []string{"/tmp/programa-nonexistent-test-99999.sock", "/var/run/programa-nonexistent.sock"} { - conn, err := dialSocket(path, nil) - if conn != nil { - conn.Close() - } - // We expect a connection error (not found), not a panic - if err == nil { - t.Fatalf("dialSocket(%q) should fail for non-existent path", path) - } - } - - // TCP addresses should attempt TCP dial - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - defer ln.Close() - - go func() { - conn, _ := ln.Accept() - if conn != nil { - conn.Close() - } - }() - - conn, err := dialSocket(ln.Addr().String(), nil) - if err != nil { - t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err) - } - conn.Close() -} - -func TestCLINewWindowUsesWindowCreateV2(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - code := runCLI([]string{"--socket", sockPath, "new-window"}) - if code != 0 { - t.Fatalf("new-window should return 0, got %d", code) - } - select { - case request := <-requests: - if request["method"] != "window.create" { - t.Fatalf("new-window sent method %v, want window.create", request["method"]) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for new-window request") - } -} - -func TestCLICloseWindowUsesWindowCloseV2WithWindowID(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"}) - if code != 0 { - t.Fatalf("close-window should return 0, got %d", code) - } - select { - case request := <-requests: - if request["method"] != "window.close" { - t.Fatalf("close-window sent method %v, want window.close", request["method"]) - } - params, _ := request["params"].(map[string]any) - if params["window_id"] != "win-42" { - t.Fatalf("close-window sent params %v, want window_id=win-42", params) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for close-window payload") - } -} - -func TestRemainingWindowCommandsUseV2Methods(t *testing.T) { - tests := []struct { - name string - args []string - wantMethod string - wantWindow string - }{ - {name: "current-window", args: []string{"current-window"}, wantMethod: "window.current"}, - {name: "focus-window", args: []string{"focus-window", "--window", "win-42"}, wantMethod: "window.focus", wantWindow: "win-42"}, - {name: "list-windows", args: []string{"list-windows"}, wantMethod: "window.list"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - if code := runCLI(append([]string{"--socket", sockPath, "--json"}, test.args...)); code != 0 { - t.Fatalf("%s returned %d", test.name, code) - } - select { - case request := <-requests: - if request["method"] != test.wantMethod { - t.Fatalf("%s sent method %v, want %s", test.name, request["method"], test.wantMethod) - } - params, _ := request["params"].(map[string]any) - if test.wantWindow != "" && params["window_id"] != test.wantWindow { - t.Fatalf("%s sent params %v, want window_id=%s", test.name, params, test.wantWindow) - } - case <-time.After(2 * time.Second): - t.Fatalf("timed out waiting for %s request", test.name) - } - }) - } -} - -func TestCLIWindowV2ServerErrorReturnsNonzero(t *testing.T) { - sockPath := makeShortUnixSocketPath(t) - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { _ = ln.Close() }) - requestCh := make(chan map[string]any, 1) - go func() { - conn, acceptErr := ln.Accept() - if acceptErr != nil { - return - } - defer conn.Close() - line, readErr := bufio.NewReader(conn).ReadBytes('\n') - if readErr != nil { - return - } - var request map[string]any - if json.Unmarshal(line, &request) != nil { - return - } - requestCh <- request - response, _ := json.Marshal(map[string]any{ - "id": request["id"], - "ok": false, - "error": map[string]any{"code": "not_found", "message": "Window not found"}, - }) - _, _ = conn.Write(append(response, '\n')) - }() - - if code := runCLI([]string{"--socket", sockPath, "focus-window", "--window", "missing"}); code == 0 { - t.Fatal("focus-window must return nonzero for a v2 server error") - } - select { - case request := <-requestCh: - if request["method"] != "window.focus" { - t.Fatalf("focus-window sent method %v, want window.focus", request["method"]) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for focus-window request") - } -} - -func TestCLIListWorkspacesV2(t *testing.T) { - sockPath := startMockV2Socket(t) - code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"}) - if code != 0 { - t.Fatalf("list-workspaces should return 0, got %d", code) - } -} - -func TestCLIListWorkspacesV2DefaultOutputShowsResult(t *testing.T) { - sockPath := startMockV2TCPSocketWithResult(t, map[string]any{"method": "workspace.list", "params": map[string]any{}}) - output := captureStdout(t, func() { - code := runCLI([]string{"--socket", sockPath, "list-workspaces"}) - if code != 0 { - t.Fatalf("list-workspaces should return 0, got %d", code) - } - }) - if !strings.Contains(output, "\"method\": \"workspace.list\"") { - t.Fatalf("expected default output to include result payload, got %q", output) - } -} - -func TestCLINotifyDefaultOutputPrintsOKForEmptyResult(t *testing.T) { - sockPath := startMockV2TCPSocketWithResult(t, map[string]any{}) - output := captureStdout(t, func() { - code := runCLI([]string{"--socket", sockPath, "notify", "--body", "hi"}) - if code != 0 { - t.Fatalf("notify should return 0, got %d", code) - } - }) - if strings.TrimSpace(output) != "OK" { - t.Fatalf("expected empty-result command to print OK, got %q", output) - } -} - -func TestCLIRPCPassthrough(t *testing.T) { - sockPath := startMockV2Socket(t) - code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"}) - if code != 0 { - t.Fatalf("rpc should return 0, got %d", code) - } -} - -func TestCLIRPCWithParams(t *testing.T) { - sockPath := startMockV2Socket(t) - code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`}) - if code != 0 { - t.Fatalf("rpc with params should return 0, got %d", code) - } -} - -func TestCLIUnknownCommand(t *testing.T) { - code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"}) - if code != 2 { - t.Fatalf("unknown command should return 2, got %d", code) - } -} - -func TestCLINoSocket(t *testing.T) { - // Without PROGRAMA_SOCKET_PATH set, should fail - os.Unsetenv("PROGRAMA_SOCKET_PATH") - code := runCLI([]string{"ping"}) - if code != 1 { - t.Fatalf("missing socket should return 1, got %d", code) - } -} - -func TestCLISocketEnvVar(t *testing.T) { - sockPath := startMockV2Socket(t) - os.Setenv("PROGRAMA_SOCKET_PATH", sockPath) - defer os.Unsetenv("PROGRAMA_SOCKET_PATH") - - code := runCLI([]string{"ping"}) - if code != 0 { - t.Fatalf("ping with env socket should return 0, got %d", code) - } -} - -func TestCLIV2FlagMapping(t *testing.T) { - // Verify that --workspace gets mapped to workspace_id in params - dir := t.TempDir() - sockPath := filepath.Join(dir, "programa.sock") - - receivedParamsCh := make(chan map[string]any, 1) - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - conn, err := ln.Accept() - if err != nil { - return - } - buf := make([]byte, 4096) - n, _ := conn.Read(buf) - var req map[string]any - json.Unmarshal(buf[:n], &req) - receivedParams, _ := req["params"].(map[string]any) - receivedParamsCh <- receivedParams - resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} - payload, _ := json.Marshal(resp) - conn.Write(append(payload, '\n')) - conn.Close() - }() - - code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"}) - if code != 0 { - t.Fatalf("close-workspace should return 0, got %d", code) - } - select { - case receivedParams := <-receivedParamsCh: - if receivedParams["workspace_id"] != "ws-abc" { - t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for close-workspace payload") - } -} - -func TestBusyboxArgv0Detection(t *testing.T) { - // Verify that when argv[0] base is "programa", we enter CLI mode - base := filepath.Base("programa") - if base != "programa" { - t.Fatalf("expected base 'programa', got %q", base) - } - base2 := filepath.Base("/home/user/.programa/bin/programa") - if base2 != "programa" { - t.Fatalf("expected base 'programa', got %q", base2) - } - base3 := filepath.Base("programad-remote") - if base3 == "programa" { - t.Fatalf("programad-remote should not match programa") - } -} - -func TestCLIBrowserSubcommand(t *testing.T) { - sockPath := startMockV2Socket(t) - code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"}) - if code != 0 { - t.Fatalf("browser open should return 0, got %d", code) - } -} - -func TestCLINewPaneDefaultsDirectionAndForwardsExtraFlags(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - code := runCLI([]string{ - "--socket", sockPath, "--json", - "new-pane", - "--workspace", "ws-1", - "--type", "browser", - "--url", "https://example.com", - }) - if code != 0 { - t.Fatalf("new-pane should return 0, got %d", code) - } - - select { - case req := <-requests: - if got := req["method"]; got != "pane.create" { - t.Fatalf("expected pane.create, got %v", got) - } - params, _ := req["params"].(map[string]any) - if got := params["workspace_id"]; got != "ws-1" { - t.Fatalf("expected workspace_id ws-1, got %v", got) - } - if got := params["direction"]; got != "right" { - t.Fatalf("expected default direction right, got %v", got) - } - if got := params["type"]; got != "browser" { - t.Fatalf("expected type browser, got %v", got) - } - if got := params["url"]; got != "https://example.com" { - t.Fatalf("expected url to be forwarded, got %v", got) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for new-pane request") - } -} - -func TestCLIListPanelsUsesSurfaceList(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - code := runCLI([]string{"--socket", sockPath, "--json", "list-panels", "--workspace", "ws-1"}) - if code != 0 { - t.Fatalf("list-panels should return 0, got %d", code) - } - - select { - case req := <-requests: - if got := req["method"]; got != "surface.list" { - t.Fatalf("expected surface.list, got %v", got) - } - params, _ := req["params"].(map[string]any) - if got := params["workspace_id"]; got != "ws-1" { - t.Fatalf("expected workspace_id ws-1, got %v", got) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for list-panels request") - } -} - -func TestCLIFocusPanelUsesSurfaceFocus(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - code := runCLI([]string{"--socket", sockPath, "--json", "focus-panel", "--workspace", "ws-1", "--panel", "surface-1"}) - if code != 0 { - t.Fatalf("focus-panel should return 0, got %d", code) - } - - select { - case req := <-requests: - if got := req["method"]; got != "surface.focus" { - t.Fatalf("expected surface.focus, got %v", got) - } - params, _ := req["params"].(map[string]any) - if got := params["workspace_id"]; got != "ws-1" { - t.Fatalf("expected workspace_id ws-1, got %v", got) - } - if got := params["surface_id"]; got != "surface-1" { - t.Fatalf("expected surface_id surface-1, got %v", got) - } - if _, ok := params["panel_id"]; ok { - t.Fatalf("did not expect panel_id in params: %v", params) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for focus-panel request") - } -} - -func TestCLIBrowserOpenUsesOpenSplitAndWorkspaceEnv(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - t.Setenv("PROGRAMA_WORKSPACE_ID", "env-ws") - code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "https://example.com"}) - if code != 0 { - t.Fatalf("browser open should return 0, got %d", code) - } - - select { - case req := <-requests: - if got := req["method"]; got != "browser.open_split" { - t.Fatalf("expected browser.open_split, got %v", got) - } - params, _ := req["params"].(map[string]any) - if got := params["workspace_id"]; got != "env-ws" { - t.Fatalf("expected workspace_id env-ws, got %v", got) - } - if got := params["url"]; got != "https://example.com" { - t.Fatalf("expected positional url to be forwarded, got %v", got) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for browser open request") - } -} - -func TestCLIBrowserGetURLUsesCurrentMethodAndSurfaceEnv(t *testing.T) { - sockPath, requests := startMockV2SocketWithRequestCapture(t) - t.Setenv("PROGRAMA_SURFACE_ID", "env-sf") - code := runCLI([]string{"--socket", sockPath, "--json", "browser", "get-url"}) - if code != 0 { - t.Fatalf("browser get-url should return 0, got %d", code) - } - - select { - case req := <-requests: - if got := req["method"]; got != "browser.url.get" { - t.Fatalf("expected browser.url.get, got %v", got) - } - params, _ := req["params"].(map[string]any) - if got := params["surface_id"]; got != "env-sf" { - t.Fatalf("expected surface_id env-sf, got %v", got) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for browser get-url request") - } -} - -func TestCLINoArgs(t *testing.T) { - code := runCLI([]string{}) - if code != 2 { - t.Fatalf("no args should return 2, got %d", code) - } -} - -func TestCLIHelpFlag(t *testing.T) { - code := runCLI([]string{"--help"}) - if code != 0 { - t.Fatalf("--help should return 0, got %d", code) - } -} - -func TestCLIHelpCommand(t *testing.T) { - code := runCLI([]string{"help"}) - if code != 0 { - t.Fatalf("help should return 0, got %d", code) - } -} - -func TestFlagToParamKey(t *testing.T) { - tests := []struct { - input, expected string - }{ - {"workspace", "workspace_id"}, - {"surface", "surface_id"}, - {"panel", "panel_id"}, - {"pane", "pane_id"}, - {"window", "window_id"}, - {"command", "initial_command"}, - {"name", "title"}, - {"working-directory", "working_directory"}, - {"title", "title"}, - {"url", "url"}, - {"direction", "direction"}, - } - for _, tc := range tests { - got := flagToParamKey(tc.input) - if got != tc.expected { - t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected) - } - } -} - -func TestParseFlags(t *testing.T) { - args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} - _, err := parseFlags(args, []string{"workspace", "surface"}) - if err == nil { - t.Fatal("parseFlags should reject unknown flags") - } -} - -func TestParseFlagsCollectsKnownFlagsAndPositionalArgs(t *testing.T) { - args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2"} - result, err := parseFlags(args, []string{"workspace", "surface"}) - if err != nil { - t.Fatalf("parseFlags should succeed for known flags: %v", err) - } - if result.flags["workspace"] != "ws-1" { - t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) - } - if result.flags["surface"] != "sf-2" { - t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) - } - if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { - t.Errorf("expected first positional=positional-cmd, got %v", result.positional) - } -} - -func TestCLIEnvVarDefaults(t *testing.T) { - // Test that PROGRAMA_WORKSPACE_ID and PROGRAMA_SURFACE_ID are used as defaults - dir := t.TempDir() - sockPath := filepath.Join(dir, "programa.sock") - - receivedParamsCh := make(chan map[string]any, 1) - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - conn, err := ln.Accept() - if err != nil { - return - } - buf := make([]byte, 4096) - n, _ := conn.Read(buf) - var req map[string]any - json.Unmarshal(buf[:n], &req) - receivedParams, _ := req["params"].(map[string]any) - receivedParamsCh <- receivedParams - resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} - payload, _ := json.Marshal(resp) - conn.Write(append(payload, '\n')) - conn.Close() - }() - - os.Setenv("PROGRAMA_WORKSPACE_ID", "env-ws-id") - os.Setenv("PROGRAMA_SURFACE_ID", "env-sf-id") - defer os.Unsetenv("PROGRAMA_WORKSPACE_ID") - defer os.Unsetenv("PROGRAMA_SURFACE_ID") - - code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"}) - if code != 0 { - t.Fatalf("close-surface should return 0, got %d", code) - } - select { - case receivedParams := <-receivedParamsCh: - if receivedParams["workspace_id"] != "env-ws-id" { - t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) - } - if receivedParams["surface_id"] != "env-sf-id" { - t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for close-surface payload") - } -} diff --git a/daemon/remote/cmd/programad-remote/main.go b/daemon/remote/cmd/programad-remote/main.go deleted file mode 100644 index 182464f5..00000000 --- a/daemon/remote/cmd/programad-remote/main.go +++ /dev/null @@ -1,438 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "math" - "net" - "os" - "path/filepath" - "strings" - "sync" - "time" -) - -var version = "dev" - -type rpcRequest struct { - ID any `json:"id"` - Method string `json:"method"` - Params map[string]any `json:"params"` -} - -type rpcError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -type rpcResponse struct { - ID any `json:"id,omitempty"` - OK bool `json:"ok"` - Result any `json:"result,omitempty"` - Error *rpcError `json:"error,omitempty"` -} - -type rpcEvent struct { - Event string `json:"event"` - StreamID string `json:"stream_id,omitempty"` - DataBase64 string `json:"data_base64,omitempty"` - Error string `json:"error,omitempty"` -} - -type streamState struct { - conn net.Conn - readerStarted bool -} - -type stdioFrameWriter struct { - mu sync.Mutex - writer *bufio.Writer -} - -type rpcServer struct { - mu sync.Mutex - nextStreamID uint64 - nextSessionID uint64 - streams map[string]*streamState - sessions map[string]*sessionState - frameWriter *stdioFrameWriter -} - -type sessionAttachment struct { - Cols int - Rows int - UpdatedAt time.Time -} - -type sessionState struct { - attachments map[string]sessionAttachment - effectiveCols int - effectiveRows int - lastKnownCols int - lastKnownRows int -} - -const maxRPCFrameBytes = 4 * 1024 * 1024 - -// The RPC method handlers implementing this dispatch table live in -// main_proxy.go (proxy.* — raw TCP stream tunneling) and -// main_sessions.go (session.* — terminal attachment/resize bookkeeping). - -func main() { - if shouldRunCLIForInvocation(os.Args[0], os.Args[1:]) { - os.Exit(runCLI(os.Args[1:])) - } - os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) -} - -func shouldRunCLIForInvocation(argv0 string, args []string) bool { - base := filepath.Base(argv0) - if base == "programa" { - return true - } - if !strings.HasPrefix(base, "programad-remote") || len(args) == 0 { - return false - } - return !isDaemonEntryCommand(args[0]) -} - -func isDaemonEntryCommand(arg string) bool { - switch arg { - case "version", "serve", "cli": - return true - default: - return false - } -} - -func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { - if len(args) == 0 { - usage(stderr) - return 2 - } - - switch args[0] { - case "version": - _, _ = fmt.Fprintln(stdout, version) - return 0 - case "serve": - fs := flag.NewFlagSet("serve", flag.ContinueOnError) - fs.SetOutput(stderr) - stdio := fs.Bool("stdio", false, "serve over stdin/stdout") - if err := fs.Parse(args[1:]); err != nil { - return 2 - } - if !*stdio { - _, _ = fmt.Fprintln(stderr, "serve requires --stdio") - return 2 - } - if err := runStdioServer(stdin, stdout); err != nil { - _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) - return 1 - } - return 0 - case "cli": - return runCLI(args[1:]) - default: - usage(stderr) - return 2 - } -} - -func usage(w io.Writer) { - _, _ = fmt.Fprintln(w, "Usage:") - _, _ = fmt.Fprintln(w, " programad-remote version") - _, _ = fmt.Fprintln(w, " programad-remote serve --stdio") - _, _ = fmt.Fprintln(w, " programad-remote cli <command> [args...]") -} - -func runStdioServer(stdin io.Reader, stdout io.Writer) error { - writer := &stdioFrameWriter{ - writer: bufio.NewWriter(stdout), - } - server := &rpcServer{ - nextStreamID: 1, - nextSessionID: 1, - streams: map[string]*streamState{}, - sessions: map[string]*sessionState{}, - frameWriter: writer, - } - defer server.closeAll() - - reader := bufio.NewReaderSize(stdin, 64*1024) - defer writer.writer.Flush() - - for { - line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) - if readErr != nil { - if errors.Is(readErr, io.EOF) { - return nil - } - return readErr - } - if oversized { - if err := writer.writeResponse(rpcResponse{ - OK: false, - Error: &rpcError{ - Code: "invalid_request", - Message: "request frame exceeds maximum size", - }, - }); err != nil { - return err - } - continue - } - line = bytes.TrimSuffix(line, []byte{'\n'}) - line = bytes.TrimSuffix(line, []byte{'\r'}) - if len(line) == 0 { - continue - } - - var req rpcRequest - if err := json.Unmarshal(line, &req); err != nil { - if err := writer.writeResponse(rpcResponse{ - OK: false, - Error: &rpcError{ - Code: "invalid_request", - Message: "invalid JSON request", - }, - }); err != nil { - return err - } - continue - } - - resp := server.handleRequest(req) - if err := writer.writeResponse(resp); err != nil { - return err - } - } -} - -func setTCPNoDelay(conn net.Conn) { - tcpConn, ok := conn.(*net.TCPConn) - if !ok { - return - } - _ = tcpConn.SetNoDelay(true) -} - -func readRPCFrame(reader *bufio.Reader, maxBytes int) ([]byte, bool, error) { - frame := make([]byte, 0, 1024) - for { - chunk, err := reader.ReadSlice('\n') - if len(chunk) > 0 { - if len(frame)+len(chunk) > maxBytes { - if errors.Is(err, bufio.ErrBufferFull) { - if drainErr := discardUntilNewline(reader); drainErr != nil && !errors.Is(drainErr, io.EOF) { - return nil, false, drainErr - } - } - return nil, true, nil - } - frame = append(frame, chunk...) - } - - if err == nil { - return frame, false, nil - } - if errors.Is(err, bufio.ErrBufferFull) { - continue - } - if errors.Is(err, io.EOF) { - if len(frame) == 0 { - return nil, false, io.EOF - } - return frame, false, nil - } - return nil, false, err - } -} - -func discardUntilNewline(reader *bufio.Reader) error { - for { - _, err := reader.ReadSlice('\n') - if err == nil || errors.Is(err, io.EOF) { - return err - } - if errors.Is(err, bufio.ErrBufferFull) { - continue - } - return err - } -} - -func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error { - return w.writeJSONFrame(resp) -} - -func (w *stdioFrameWriter) writeEvent(event rpcEvent) error { - return w.writeJSONFrame(event) -} - -func (w *stdioFrameWriter) writeJSONFrame(payload any) error { - data, err := json.Marshal(payload) - if err != nil { - return err - } - w.mu.Lock() - defer w.mu.Unlock() - if _, err := w.writer.Write(data); err != nil { - return err - } - if err := w.writer.WriteByte('\n'); err != nil { - return err - } - return w.writer.Flush() -} - -func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { - if req.Method == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_request", - Message: "method is required", - }, - } - } - - switch req.Method { - case "hello": - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "name": "programad-remote", - "version": version, - "capabilities": []string{ - "session.basic", - "session.resize.min", - "proxy.http_connect", - "proxy.socks5", - "proxy.stream", - "proxy.stream.push", - }, - }, - } - case "ping": - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "pong": true, - }, - } - case "proxy.open": - return s.handleProxyOpen(req) - case "proxy.close": - return s.handleProxyClose(req) - case "proxy.write": - return s.handleProxyWrite(req) - case "proxy.stream.subscribe": - return s.handleProxyStreamSubscribe(req) - case "session.open": - return s.handleSessionOpen(req) - case "session.close": - return s.handleSessionClose(req) - case "session.attach": - return s.handleSessionAttach(req) - case "session.resize": - return s.handleSessionResize(req) - case "session.detach": - return s.handleSessionDetach(req) - case "session.status": - return s.handleSessionStatus(req) - default: - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "method_not_found", - Message: fmt.Sprintf("unknown method %q", req.Method), - }, - } - } -} - -// closeAll tears down every open proxy stream and clears session state. -// It touches both s.streams and s.sessions, so it lives here rather than -// in either handler file. -func (s *rpcServer) closeAll() { - s.mu.Lock() - streams := make([]net.Conn, 0, len(s.streams)) - for id, state := range s.streams { - delete(s.streams, id) - streams = append(streams, state.conn) - } - for id := range s.sessions { - delete(s.sessions, id) - } - s.mu.Unlock() - for _, conn := range streams { - _ = conn.Close() - } -} - -func getStringParam(params map[string]any, key string) (string, bool) { - if params == nil { - return "", false - } - raw, ok := params[key] - if !ok || raw == nil { - return "", false - } - value, ok := raw.(string) - return value, ok -} - -func getIntParam(params map[string]any, key string) (int, bool) { - if params == nil { - return 0, false - } - raw, ok := params[key] - if !ok || raw == nil { - return 0, false - } - switch value := raw.(type) { - case int: - return value, true - case int8: - return int(value), true - case int16: - return int(value), true - case int32: - return int(value), true - case int64: - return int(value), true - case uint: - return int(value), true - case uint8: - return int(value), true - case uint16: - return int(value), true - case uint32: - return int(value), true - case uint64: - return int(value), true - case float64: - if math.Trunc(value) != value { - return 0, false - } - return int(value), true - case json.Number: - n, err := value.Int64() - if err != nil { - return 0, false - } - return int(n), true - default: - return 0, false - } -} diff --git a/daemon/remote/cmd/programad-remote/main_proxy.go b/daemon/remote/cmd/programad-remote/main_proxy.go deleted file mode 100644 index f9e72c83..00000000 --- a/daemon/remote/cmd/programad-remote/main_proxy.go +++ /dev/null @@ -1,338 +0,0 @@ -package main - -import ( - "encoding/base64" - "errors" - "fmt" - "io" - "net" - "strconv" - "time" -) - -// --- proxy.* RPC handlers: raw TCP stream tunneling over the daemon's -// stdio RPC connection (proxy.open/close/write/stream.subscribe). --- - -func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { - host, ok := getStringParam(req.Params, "host") - if !ok || host == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.open requires host", - }, - } - } - port, ok := getIntParam(req.Params, "port") - if !ok || port <= 0 || port > 65535 { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.open requires port in range 1-65535", - }, - } - } - - timeoutMs := 10000 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { - timeoutMs = parsed - } - - conn, err := net.DialTimeout( - "tcp", - net.JoinHostPort(host, strconv.Itoa(port)), - time.Duration(timeoutMs)*time.Millisecond, - ) - if err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "open_failed", - Message: err.Error(), - }, - } - } - setTCPNoDelay(conn) - - s.mu.Lock() - streamID := fmt.Sprintf("s-%d", s.nextStreamID) - s.nextStreamID++ - s.streams[streamID] = &streamState{conn: conn} - s.mu.Unlock() - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "stream_id": streamID, - }, - } -} - -func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.close requires stream_id", - }, - } - } - - s.mu.Lock() - state, exists := s.streams[streamID] - if exists { - delete(s.streams, streamID) - } - s.mu.Unlock() - - if !exists { - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "closed": true, - }, - } - } - - _ = state.conn.Close() - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "closed": true, - }, - } -} - -func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.write requires stream_id", - }, - } - } - dataBase64, ok := getStringParam(req.Params, "data_base64") - if !ok { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.write requires data_base64", - }, - } - } - payload, err := base64.StdEncoding.DecodeString(dataBase64) - if err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "data_base64 must be valid base64", - }, - } - } - - state, found := s.getStream(streamID) - if !found { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", - }, - } - } - conn := state.conn - - timeoutMs := 8000 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { - timeoutMs = parsed - } - if timeoutMs > 0 { - if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: err.Error(), - }, - } - } - defer conn.SetWriteDeadline(time.Time{}) - } - - total := 0 - for total < len(payload) { - written, writeErr := conn.Write(payload[total:]) - if written == 0 && writeErr == nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: "write made no progress", - }, - } - } - total += written - if writeErr != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: writeErr.Error(), - }, - } - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "written": total, - }, - } -} - -func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.stream.subscribe requires stream_id", - }, - } - } - - s.mu.Lock() - state, found := s.streams[streamID] - if !found { - s.mu.Unlock() - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", - }, - } - } - alreadySubscribed := state.readerStarted - if !alreadySubscribed { - state.readerStarted = true - } - conn := state.conn - s.mu.Unlock() - - if !alreadySubscribed { - go s.streamPump(streamID, conn) - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "subscribed": true, - "already_subscribed": alreadySubscribed, - }, - } -} - -func (s *rpcServer) getStream(streamID string) (*streamState, bool) { - s.mu.Lock() - defer s.mu.Unlock() - state, ok := s.streams[streamID] - return state, ok -} - -func (s *rpcServer) dropStream(streamID string) { - s.mu.Lock() - state, ok := s.streams[streamID] - if ok { - delete(s.streams, streamID) - } - s.mu.Unlock() - if ok { - _ = state.conn.Close() - } -} - -func (s *rpcServer) streamPump(streamID string, conn net.Conn) { - defer func() { - if recovered := recover(); recovered != nil { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: fmt.Sprintf("stream panic: %v", recovered), - }) - s.dropStream(streamID) - } - }() - - buffer := make([]byte, 32768) - for { - n, readErr := conn.Read(buffer) - data := append([]byte(nil), buffer[:max(0, n)]...) - if len(data) > 0 { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.data", - StreamID: streamID, - DataBase64: base64.StdEncoding.EncodeToString(data), - }) - } - - if readErr == nil { - if n == 0 { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: "read made no progress", - }) - s.dropStream(streamID) - return - } - continue - } - - if readErr == io.EOF { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.eof", - StreamID: streamID, - DataBase64: "", - }) - } else if !errors.Is(readErr, net.ErrClosed) { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: readErr.Error(), - }) - } - - s.dropStream(streamID) - return - } -} diff --git a/daemon/remote/cmd/programad-remote/main_sessions.go b/daemon/remote/cmd/programad-remote/main_sessions.go deleted file mode 100644 index a3cadd65..00000000 --- a/daemon/remote/cmd/programad-remote/main_sessions.go +++ /dev/null @@ -1,357 +0,0 @@ -package main - -import ( - "fmt" - "sort" - "time" -) - -// --- session.* RPC handlers: terminal session attachment/resize -// bookkeeping (session.open/close/attach/resize/detach/status). --- - -func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { - sessionID, _ := getStringParam(req.Params, "session_id") - - s.mu.Lock() - defer s.mu.Unlock() - - if sessionID == "" { - sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) - s.nextSessionID++ - } - - session, exists := s.sessions[sessionID] - if !exists { - session = &sessionState{ - attachments: map[string]sessionAttachment{}, - } - s.sessions[sessionID] = session - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.close requires session_id", - }, - } - } - - s.mu.Lock() - _, exists := s.sessions[sessionID] - if exists { - delete(s.sessions, sessionID) - } - s.mu.Unlock() - - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "session_id": sessionID, - "closed": true, - }, - } -} - -func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { - sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") - if badResp != nil { - return *badResp - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - session.attachments[attachmentID] = sessionAttachment{ - Cols: cols, - Rows: rows, - UpdatedAt: time.Now().UTC(), - } - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { - sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") - if badResp != nil { - return *badResp - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - if _, exists := session.attachments[attachmentID]; !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "attachment not found", - }, - } - } - - session.attachments[attachmentID] = sessionAttachment{ - Cols: cols, - Rows: rows, - UpdatedAt: time.Now().UTC(), - } - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.detach requires session_id", - }, - } - } - attachmentID, ok := getStringParam(req.Params, "attachment_id") - if !ok || attachmentID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.detach requires attachment_id", - }, - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - if _, exists := session.attachments[attachmentID]; !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "attachment not found", - }, - } - } - - delete(session.attachments, attachmentID) - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.status requires session_id", - }, - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires session_id", - }, - } - return "", "", 0, 0, &resp - } - attachmentID, ok = getStringParam(req.Params, "attachment_id") - if !ok || attachmentID == "" { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires attachment_id", - }, - } - return "", "", 0, 0, &resp - } - - cols, ok = getIntParam(req.Params, "cols") - if !ok || cols <= 0 { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires cols > 0", - }, - } - return "", "", 0, 0, &resp - } - rows, ok = getIntParam(req.Params, "rows") - if !ok || rows <= 0 { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires rows > 0", - }, - } - return "", "", 0, 0, &resp - } - - return sessionID, attachmentID, cols, rows, nil -} - -func recomputeSessionSize(session *sessionState) { - if len(session.attachments) == 0 { - session.effectiveCols = session.lastKnownCols - session.effectiveRows = session.lastKnownRows - return - } - - minCols := 0 - minRows := 0 - for _, attachment := range session.attachments { - if minCols == 0 || attachment.Cols < minCols { - minCols = attachment.Cols - } - if minRows == 0 || attachment.Rows < minRows { - minRows = attachment.Rows - } - } - - session.effectiveCols = minCols - session.effectiveRows = minRows - session.lastKnownCols = minCols - session.lastKnownRows = minRows -} - -func sessionSnapshot(sessionID string, session *sessionState) map[string]any { - attachmentIDs := make([]string, 0, len(session.attachments)) - for attachmentID := range session.attachments { - attachmentIDs = append(attachmentIDs, attachmentID) - } - sort.Strings(attachmentIDs) - - attachments := make([]map[string]any, 0, len(attachmentIDs)) - for _, attachmentID := range attachmentIDs { - attachment := session.attachments[attachmentID] - attachments = append(attachments, map[string]any{ - "attachment_id": attachmentID, - "cols": attachment.Cols, - "rows": attachment.Rows, - "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), - }) - } - - return map[string]any{ - "session_id": sessionID, - "attachments": attachments, - "effective_cols": session.effectiveCols, - "effective_rows": session.effectiveRows, - "last_known_cols": session.lastKnownCols, - "last_known_rows": session.lastKnownRows, - } -} diff --git a/daemon/remote/cmd/programad-remote/main_test.go b/daemon/remote/cmd/programad-remote/main_test.go deleted file mode 100644 index e6c266ad..00000000 --- a/daemon/remote/cmd/programad-remote/main_test.go +++ /dev/null @@ -1,755 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/base64" - "encoding/json" - "io" - "math" - "net" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "testing" - "time" -) - -type notifyingBuffer struct { - mu sync.Mutex - buffer bytes.Buffer - notify chan struct{} -} - -func newNotifyingBuffer() *notifyingBuffer { - return ¬ifyingBuffer{notify: make(chan struct{}, 1)} -} - -func (b *notifyingBuffer) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - n, err := b.buffer.Write(p) - if n > 0 { - select { - case b.notify <- struct{}{}: - default: - } - } - return n, err -} - -func (b *notifyingBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buffer.String() -} - -type eofWithPayloadConn struct { - payload []byte - readOnce bool -} - -func (c *eofWithPayloadConn) Read(p []byte) (int, error) { - if c.readOnce { - return 0, io.EOF - } - c.readOnce = true - n := copy(p, c.payload) - return n, io.EOF -} - -func (c *eofWithPayloadConn) Write(p []byte) (int, error) { - return len(p), nil -} - -func (c *eofWithPayloadConn) Close() error { return nil } -func (c *eofWithPayloadConn) LocalAddr() net.Addr { - return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} -} -func (c *eofWithPayloadConn) RemoteAddr() net.Addr { - return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} -} -func (c *eofWithPayloadConn) SetDeadline(time.Time) error { return nil } -func (c *eofWithPayloadConn) SetReadDeadline(time.Time) error { return nil } -func (c *eofWithPayloadConn) SetWriteDeadline(time.Time) error { return nil } - -func TestRunVersion(t *testing.T) { - var out bytes.Buffer - code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) - if code != 0 { - t.Fatalf("run version exit code = %d, want 0", code) - } - if strings.TrimSpace(out.String()) == "" { - t.Fatalf("version output should not be empty") - } -} - -func TestWrapperBinaryDispatchesIntoCLI(t *testing.T) { - if os.Getenv("PROGRAMAD_REMOTE_MAIN_HELPER") == "1" { - separator := 0 - for i, arg := range os.Args { - if arg == "--" { - separator = i - break - } - } - if separator == 0 { - t.Fatal("helper process missing -- separator") - } - os.Args = append([]string{os.Args[0]}, os.Args[separator+1:]...) - main() - return - } - - sockPath := startMockV2Socket(t) - wrapperPath := filepath.Join(t.TempDir(), "programad-remote-current") - if err := os.Symlink(os.Args[0], wrapperPath); err != nil { - t.Fatalf("symlink wrapper path: %v", err) - } - - cmd := exec.Command( - wrapperPath, - "-test.run=TestWrapperBinaryDispatchesIntoCLI", - "--", - "--socket", sockPath, "ping", - ) - cmd.Env = append(os.Environ(), "PROGRAMAD_REMOTE_MAIN_HELPER=1") - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("wrapper invocation failed: %v\n%s", err, output) - } - - if got := strings.TrimSpace(string(output)); got != "PONG" { - t.Fatalf("wrapper invocation output = %q, want %q", got, "PONG") - } -} - -func TestRunStdioHelloAndPing(t *testing.T) { - input := strings.NewReader( - `{"id":1,"method":"hello","params":{}}` + "\n" + - `{"id":2,"method":"ping","params":{}}` + "\n", - ) - var out bytes.Buffer - code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) - if code != 0 { - t.Fatalf("run serve exit code = %d, want 0", code) - } - - lines := strings.Split(strings.TrimSpace(out.String()), "\n") - if len(lines) != 2 { - t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) - } - - var first map[string]any - if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { - t.Fatalf("failed to decode first response: %v", err) - } - if ok, _ := first["ok"].(bool); !ok { - t.Fatalf("first response should be ok=true: %v", first) - } - firstResult, _ := first["result"].(map[string]any) - if firstResult == nil { - t.Fatalf("first response missing result object: %v", first) - } - capabilities, _ := firstResult["capabilities"].([]any) - if len(capabilities) < 2 { - t.Fatalf("hello should return capabilities: %v", firstResult) - } - var sawPushCapability bool - for _, capability := range capabilities { - if capability == "proxy.stream.push" { - sawPushCapability = true - break - } - } - if !sawPushCapability { - t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult) - } - - var second map[string]any - if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { - t.Fatalf("failed to decode second response: %v", err) - } - if ok, _ := second["ok"].(bool); !ok { - t.Fatalf("second response should be ok=true: %v", second) - } -} - -func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { - input := strings.NewReader( - `{"id":1,"method":"hello","params":{}` + "\n" + - `{"id":2,"method":"unknown","params":{}}` + "\n", - ) - var out bytes.Buffer - code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) - if code != 0 { - t.Fatalf("run serve exit code = %d, want 0", code) - } - - lines := strings.Split(strings.TrimSpace(out.String()), "\n") - if len(lines) != 2 { - t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) - } - - var first map[string]any - if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { - t.Fatalf("failed to decode first response: %v", err) - } - if ok, _ := first["ok"].(bool); ok { - t.Fatalf("first response should be ok=false for invalid JSON: %v", first) - } - firstError, _ := first["error"].(map[string]any) - if got := firstError["code"]; got != "invalid_request" { - t.Fatalf("invalid JSON should return invalid_request; got=%v payload=%v", got, first) - } - - var second map[string]any - if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { - t.Fatalf("failed to decode second response: %v", err) - } - if ok, _ := second["ok"].(bool); ok { - t.Fatalf("second response should be ok=false for unknown method: %v", second) - } - secondError, _ := second["error"].(map[string]any) - if got := secondError["code"]; got != "method_not_found" { - t.Fatalf("unknown method should return method_not_found; got=%v payload=%v", got, second) - } -} - -func TestRunStdioSessionResizeFlow(t *testing.T) { - input := strings.NewReader( - `{"id":1,"method":"session.open","params":{"session_id":"sess-stdio"}}` + "\n" + - `{"id":2,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a1","cols":120,"rows":40}}` + "\n" + - `{"id":3,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a2","cols":90,"rows":30}}` + "\n" + - `{"id":4,"method":"session.status","params":{"session_id":"sess-stdio"}}` + "\n", - ) - var out bytes.Buffer - code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) - if code != 0 { - t.Fatalf("run serve exit code = %d, want 0", code) - } - - lines := strings.Split(strings.TrimSpace(out.String()), "\n") - if len(lines) != 4 { - t.Fatalf("got %d response lines, want 4: %q", len(lines), out.String()) - } - - var status map[string]any - if err := json.Unmarshal([]byte(lines[3]), &status); err != nil { - t.Fatalf("failed to decode status response: %v", err) - } - if ok, _ := status["ok"].(bool); !ok { - t.Fatalf("session.status should be ok=true: %v", status) - } - result, _ := status["result"].(map[string]any) - if result == nil { - t.Fatalf("session.status missing result object: %v", status) - } - effectiveCols, _ := result["effective_cols"].(float64) - effectiveRows, _ := result["effective_rows"].(float64) - if int(effectiveCols) != 90 || int(effectiveRows) != 30 { - t.Fatalf("session smallest-wins effective size mismatch: got=%vx%v payload=%v", effectiveCols, effectiveRows, result) - } -} - -func TestProxyStreamRoundTrip(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - defer listener.Close() - - done := make(chan struct{}) - go func() { - defer close(done) - conn, acceptErr := listener.Accept() - if acceptErr != nil { - return - } - defer conn.Close() - - buffer := make([]byte, 4) - if _, readErr := io.ReadFull(conn, buffer); readErr != nil { - return - } - if string(buffer) != "ping" { - return - } - _, _ = conn.Write([]byte("pong")) - }() - - eventOutput := newNotifyingBuffer() - server := &rpcServer{ - nextStreamID: 1, - nextSessionID: 1, - streams: map[string]*streamState{}, - sessions: map[string]*sessionState{}, - frameWriter: &stdioFrameWriter{ - writer: bufio.NewWriter(eventOutput), - }, - } - defer server.closeAll() - - port := listener.Addr().(*net.TCPAddr).Port - openResp := server.handleRequest(rpcRequest{ - ID: 1, - Method: "proxy.open", - Params: map[string]any{ - "host": "127.0.0.1", - "port": port, - "timeout_ms": 1000, - }, - }) - if !openResp.OK { - t.Fatalf("proxy.open failed: %+v", openResp) - } - openResult, _ := openResp.Result.(map[string]any) - streamID, _ := openResult["stream_id"].(string) - if streamID == "" { - t.Fatalf("proxy.open missing stream_id: %+v", openResp) - } - - writeResp := server.handleRequest(rpcRequest{ - ID: 2, - Method: "proxy.write", - Params: map[string]any{ - "stream_id": streamID, - "data_base64": base64.StdEncoding.EncodeToString([]byte("ping")), - }, - }) - if !writeResp.OK { - t.Fatalf("proxy.write failed: %+v", writeResp) - } - - readResp := server.handleRequest(rpcRequest{ - ID: 3, - Method: "proxy.stream.subscribe", - Params: map[string]any{ - "stream_id": streamID, - }, - }) - if !readResp.OK { - t.Fatalf("proxy.stream.subscribe failed: %+v", readResp) - } - select { - case <-eventOutput.notify: - case <-time.After(2 * time.Second): - t.Fatalf("timed out waiting for proxy.stream.data event") - } - - lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") - if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { - t.Fatalf("proxy.stream.data event output was empty") - } - - var event map[string]any - if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { - t.Fatalf("failed to decode stream event: %v", err) - } - if got := event["event"]; got != "proxy.stream.data" { - t.Fatalf("unexpected stream event=%v payload=%v", got, event) - } - dataBase64, _ := event["data_base64"].(string) - data, decodeErr := base64.StdEncoding.DecodeString(dataBase64) - if decodeErr != nil { - t.Fatalf("proxy.stream.data returned invalid base64: %v", decodeErr) - } - if string(data) != "pong" { - t.Fatalf("proxy.stream.data payload=%q, want %q", string(data), "pong") - } - - closeResp := server.handleRequest(rpcRequest{ - ID: 4, - Method: "proxy.close", - Params: map[string]any{ - "stream_id": streamID, - }, - }) - if !closeResp.OK { - t.Fatalf("proxy.close failed: %+v", closeResp) - } - - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatalf("proxy test server goroutine did not finish") - } -} - -func TestProxyStreamEOFPayloadIsNotDuplicatedAcrossDataAndEOFEvents(t *testing.T) { - eventOutput := newNotifyingBuffer() - server := &rpcServer{ - nextStreamID: 1, - nextSessionID: 1, - streams: map[string]*streamState{ - "stream-1": { - conn: &eofWithPayloadConn{payload: []byte("tail")}, - }, - }, - sessions: map[string]*sessionState{}, - frameWriter: &stdioFrameWriter{ - writer: bufio.NewWriter(eventOutput), - }, - } - defer server.closeAll() - - resp := server.handleRequest(rpcRequest{ - ID: 1, - Method: "proxy.stream.subscribe", - Params: map[string]any{"stream_id": "stream-1"}, - }) - if !resp.OK { - t.Fatalf("proxy.stream.subscribe failed: %+v", resp) - } - - deadline := time.Now().Add(2 * time.Second) - for strings.Count(strings.TrimSpace(eventOutput.String()), "\n")+boolToInt(strings.TrimSpace(eventOutput.String()) != "") < 2 { - remaining := time.Until(deadline) - if remaining <= 0 { - t.Fatalf("timed out waiting for proxy stream events: %q", eventOutput.String()) - } - select { - case <-eventOutput.notify: - case <-time.After(remaining): - t.Fatalf("timed out waiting for proxy stream events: %q", eventOutput.String()) - } - } - - lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") - if len(lines) != 2 { - t.Fatalf("expected exactly 2 stream events, got %d: %q", len(lines), eventOutput.String()) - } - - var first map[string]any - if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { - t.Fatalf("decode first event: %v", err) - } - var second map[string]any - if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { - t.Fatalf("decode second event: %v", err) - } - - if got := first["event"]; got != "proxy.stream.data" { - t.Fatalf("first event = %v, want proxy.stream.data", got) - } - if got := second["event"]; got != "proxy.stream.eof" { - t.Fatalf("second event = %v, want proxy.stream.eof", got) - } - - firstPayload, err := base64.StdEncoding.DecodeString(first["data_base64"].(string)) - if err != nil { - t.Fatalf("decode first payload: %v", err) - } - secondPayload, err := decodeOptionalBase64(second["data_base64"]) - if err != nil { - t.Fatalf("decode second payload: %v", err) - } - - if string(firstPayload) != "tail" { - t.Fatalf("proxy.stream.data payload = %q, want %q", string(firstPayload), "tail") - } - if len(secondPayload) != 0 { - t.Fatalf("proxy.stream.eof payload = %q, want empty payload after data event", string(secondPayload)) - } -} - -func boolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -func decodeOptionalBase64(value any) ([]byte, error) { - encoded, ok := value.(string) - if !ok || encoded == "" { - return nil, nil - } - return base64.StdEncoding.DecodeString(encoded) -} - -func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { - params := map[string]any{ - "port": 80.9, - "timeout_ms": 100.0, - } - - if _, ok := getIntParam(params, "port"); ok { - t.Fatalf("fractional float64 should be rejected") - } - - timeout, ok := getIntParam(params, "timeout_ms") - if !ok { - t.Fatalf("integral float64 should be accepted") - } - if timeout != 100 { - t.Fatalf("timeout_ms = %d, want 100", timeout) - } -} - -func TestRunStdioOversizedFrameContinuesServing(t *testing.T) { - oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}` - input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n") - var out bytes.Buffer - code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) - if code != 0 { - t.Fatalf("run serve exit code = %d, want 0", code) - } - - lines := strings.Split(strings.TrimSpace(out.String()), "\n") - if len(lines) != 2 { - t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) - } - - var first map[string]any - if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { - t.Fatalf("failed to decode first response: %v", err) - } - if ok, _ := first["ok"].(bool); ok { - t.Fatalf("first response should be oversized-frame error: %v", first) - } - firstError, _ := first["error"].(map[string]any) - if got := firstError["code"]; got != "invalid_request" { - t.Fatalf("oversized frame should return invalid_request; got=%v payload=%v", got, first) - } - - var second map[string]any - if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { - t.Fatalf("failed to decode second response: %v", err) - } - if ok, _ := second["ok"].(bool); !ok { - t.Fatalf("second response should still be handled after oversized frame: %v", second) - } -} - -func TestProxyOpenInvalidParams(t *testing.T) { - server := &rpcServer{ - nextStreamID: 1, - nextSessionID: 1, - streams: map[string]*streamState{}, - sessions: map[string]*sessionState{}, - } - defer server.closeAll() - - resp := server.handleRequest(rpcRequest{ - ID: 1, - Method: "proxy.open", - Params: map[string]any{ - "host": "127.0.0.1", - "port": strconv.Itoa(8080), - }, - }) - if resp.OK { - t.Fatalf("proxy.open with invalid port type should fail: %+v", resp) - } - errObj, _ := resp.Error, resp.Error - if errObj == nil || errObj.Code != "invalid_params" { - t.Fatalf("proxy.open invalid params should return invalid_params: %+v", resp) - } -} - -func TestSessionResizeCoordinator(t *testing.T) { - server := &rpcServer{ - nextStreamID: 1, - nextSessionID: 1, - streams: map[string]*streamState{}, - sessions: map[string]*sessionState{}, - } - defer server.closeAll() - - openResp := server.handleRequest(rpcRequest{ - ID: 1, - Method: "session.open", - Params: map[string]any{ - "session_id": "sess-rz", - }, - }) - if !openResp.OK { - t.Fatalf("session.open failed: %+v", openResp) - } - - attachSmall := server.handleRequest(rpcRequest{ - ID: 2, - Method: "session.attach", - Params: map[string]any{ - "session_id": "sess-rz", - "attachment_id": "a-small", - "cols": 90, - "rows": 30, - }, - }) - assertEffectiveSize(t, attachSmall, 90, 30) - - attachLarge := server.handleRequest(rpcRequest{ - ID: 3, - Method: "session.attach", - Params: map[string]any{ - "session_id": "sess-rz", - "attachment_id": "a-large", - "cols": 120, - "rows": 40, - }, - }) - assertEffectiveSize(t, attachLarge, 90, 30) // RZ-001: smallest wins - - resizeLarge := server.handleRequest(rpcRequest{ - ID: 4, - Method: "session.resize", - Params: map[string]any{ - "session_id": "sess-rz", - "attachment_id": "a-large", - "cols": 200, - "rows": 60, - }, - }) - assertEffectiveSize(t, resizeLarge, 90, 30) // RZ-002: still bounded by smallest - - detachSmall := server.handleRequest(rpcRequest{ - ID: 5, - Method: "session.detach", - Params: map[string]any{ - "session_id": "sess-rz", - "attachment_id": "a-small", - }, - }) - assertEffectiveSize(t, detachSmall, 200, 60) // RZ-003: expands to next smallest - - detachLarge := server.handleRequest(rpcRequest{ - ID: 6, - Method: "session.detach", - Params: map[string]any{ - "session_id": "sess-rz", - "attachment_id": "a-large", - }, - }) - assertEffectiveSize(t, detachLarge, 200, 60) // no attachments: keep last-known size - assertAttachmentCount(t, detachLarge, 0) - - reattach := server.handleRequest(rpcRequest{ - ID: 7, - Method: "session.attach", - Params: map[string]any{ - "session_id": "sess-rz", - "attachment_id": "a-reconnect", - "cols": 110, - "rows": 50, - }, - }) - assertEffectiveSize(t, reattach, 110, 50) // RZ-004: recompute from active attachments on reattach -} - -func TestSessionInvalidParamsAndNotFound(t *testing.T) { - server := &rpcServer{ - nextStreamID: 1, - nextSessionID: 1, - streams: map[string]*streamState{}, - sessions: map[string]*sessionState{}, - } - defer server.closeAll() - - missingSession := server.handleRequest(rpcRequest{ - ID: 1, - Method: "session.attach", - Params: map[string]any{ - "session_id": "missing", - "attachment_id": "a1", - "cols": 80, - "rows": 24, - }, - }) - if missingSession.OK || missingSession.Error == nil || missingSession.Error.Code != "not_found" { - t.Fatalf("session.attach on missing session should return not_found: %+v", missingSession) - } - - badSize := server.handleRequest(rpcRequest{ - ID: 2, - Method: "session.attach", - Params: map[string]any{ - "session_id": "missing", - "attachment_id": "a1", - "cols": 0, - "rows": 24, - }, - }) - if badSize.OK || badSize.Error == nil || badSize.Error.Code != "invalid_params" { - t.Fatalf("session.attach with cols=0 should return invalid_params: %+v", badSize) - } -} - -func assertEffectiveSize(t *testing.T, resp rpcResponse, wantCols, wantRows int) { - t.Helper() - if !resp.OK { - t.Fatalf("expected ok response, got error: %+v", resp) - } - result, ok := resp.Result.(map[string]any) - if !ok { - t.Fatalf("response missing result map: %+v", resp) - } - gotCols := asInt(t, result["effective_cols"], "effective_cols") - gotRows := asInt(t, result["effective_rows"], "effective_rows") - if gotCols != wantCols || gotRows != wantRows { - t.Fatalf("effective size = %dx%d, want %dx%d payload=%+v", gotCols, gotRows, wantCols, wantRows, result) - } -} - -func assertAttachmentCount(t *testing.T, resp rpcResponse, want int) { - t.Helper() - if !resp.OK { - t.Fatalf("expected ok response, got error: %+v", resp) - } - result, ok := resp.Result.(map[string]any) - if !ok { - t.Fatalf("response missing result map: %+v", resp) - } - attachments, ok := result["attachments"].([]map[string]any) - if ok { - if len(attachments) != want { - t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachments), want, result) - } - return - } - attachmentsAny, ok := result["attachments"].([]any) - if !ok { - t.Fatalf("attachments field has unexpected type (%T) payload=%+v", result["attachments"], result) - } - if len(attachmentsAny) != want { - t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachmentsAny), want, result) - } -} - -func asInt(t *testing.T, value any, field string) int { - t.Helper() - switch typed := value.(type) { - case int: - return typed - case int8: - return int(typed) - case int16: - return int(typed) - case int32: - return int(typed) - case int64: - return int(typed) - case uint: - return int(typed) - case uint8: - return int(typed) - case uint16: - return int(typed) - case uint32: - return int(typed) - case uint64: - return int(typed) - case float64: - if typed != math.Trunc(typed) { - t.Fatalf("%s should be integer-valued, got %v", field, typed) - } - return int(typed) - default: - t.Fatalf("%s has unexpected type %T (%v)", field, value, value) - return 0 - } -} diff --git a/daemon/remote/cmd/programad-remote/tmux_args.go b/daemon/remote/cmd/programad-remote/tmux_args.go deleted file mode 100644 index c2185b53..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_args.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -// --- Tmux argument parsing --- - -type tmuxParsed struct { - flags map[string]bool // boolean flags like -d, -P - options map[string][]string // value flags like -t <target> - positional []string -} - -func (p *tmuxParsed) hasFlag(f string) bool { - return p.flags[f] -} - -func (p *tmuxParsed) value(f string) string { - vals := p.options[f] - if len(vals) == 0 { - return "" - } - return vals[len(vals)-1] -} - -func splitTmuxCmd(args []string) (string, []string, error) { - globalValueFlags := map[string]bool{"-L": true, "-S": true, "-f": true} - globalBoolFlags := map[string]bool{"-V": true, "-v": true} - - i := 0 - for i < len(args) { - arg := args[i] - if !strings.HasPrefix(arg, "-") || arg == "-" { - return strings.ToLower(arg), args[i+1:], nil - } - if arg == "--" { - break - } - if globalBoolFlags[arg] { - return arg, nil, nil - } - if globalValueFlags[arg] { - // Skip the value - i++ - } - i++ - } - return "", nil, fmt.Errorf("tmux shim requires a command") -} - -func parseTmuxArgs(args []string, valueFlags, boolFlags []string) *tmuxParsed { - vSet := make(map[string]bool, len(valueFlags)) - for _, f := range valueFlags { - vSet[f] = true - } - bSet := make(map[string]bool, len(boolFlags)) - for _, f := range boolFlags { - bSet[f] = true - } - - p := &tmuxParsed{ - flags: make(map[string]bool), - options: make(map[string][]string), - } - pastTerminator := false - - for i := 0; i < len(args); i++ { - arg := args[i] - if pastTerminator { - p.positional = append(p.positional, arg) - continue - } - if arg == "--" { - pastTerminator = true - continue - } - if !strings.HasPrefix(arg, "-") || arg == "-" { - p.positional = append(p.positional, arg) - continue - } - if strings.HasPrefix(arg, "--") { - p.positional = append(p.positional, arg) - continue - } - - // Cluster parsing: -dPh etc. - cluster := []rune(arg[1:]) - cursor := 0 - recognized := false - for cursor < len(cluster) { - flag := "-" + string(cluster[cursor]) - if bSet[flag] { - p.flags[flag] = true - cursor++ - recognized = true - continue - } - if vSet[flag] { - remainder := string(cluster[cursor+1:]) - var value string - if remainder != "" { - value = remainder - } else if i+1 < len(args) { - i++ - value = args[i] - } - p.options[flag] = append(p.options[flag], value) - recognized = true - cursor = len(cluster) - continue - } - recognized = false - break - } - if !recognized { - p.positional = append(p.positional, arg) - } - } - return p -} diff --git a/daemon/remote/cmd/programad-remote/tmux_commands.go b/daemon/remote/cmd/programad-remote/tmux_commands.go deleted file mode 100644 index 415bee72..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_commands.go +++ /dev/null @@ -1,808 +0,0 @@ -package main - -import ( - "fmt" - "math" - "os" - "strings" - "time" -) - -// --- Command implementations --- - -func tmuxIsClaudeTeamWorkspace(item map[string]any) bool { - workspaceId, _ := item["id"].(string) - if workspaceId == "" { - return false - } - helpers := []any{} - switch rawHelpers := item["helpers"].(type) { - case []any: - helpers = rawHelpers - case []map[string]any: - for _, helper := range rawHelpers { - helpers = append(helpers, helper) - } - } - for _, rawHelper := range helpers { - helper, _ := rawHelper.(map[string]any) - if helper == nil { - continue - } - host, _ := helper["host"].(string) - helperWorkspaceId, _ := helper["workspace_id"].(string) - if host == "claude-teams" && helperWorkspaceId == workspaceId { - return true - } - } - return false -} - -func tmuxTeamWorkspaceIds(workspaceId string, workspaceItems []map[string]any) []string { - orderedIds := make([]string, 0, len(workspaceItems)) - liveWorkspaceIds := make(map[string]bool, len(workspaceItems)) - teamWorkspaceIds := make(map[string]bool, len(workspaceItems)) - orderById := make(map[string]int, len(workspaceItems)) - for _, item := range workspaceItems { - id, _ := item["id"].(string) - if id == "" { - continue - } - orderById[id] = len(orderedIds) - orderedIds = append(orderedIds, id) - liveWorkspaceIds[id] = true - if tmuxIsClaudeTeamWorkspace(item) { - teamWorkspaceIds[id] = true - } - } - - parentById := make(map[string]string, len(teamWorkspaceIds)) - for _, item := range workspaceItems { - id, _ := item["id"].(string) - parentId, _ := item["agent_parent_workspace_id"].(string) - if teamWorkspaceIds[id] && liveWorkspaceIds[parentId] { - parentById[id] = parentId - } - } - - lineage := []string{workspaceId} - lineageIndex := map[string]int{workspaceId: 0} - rootWorkspaceId := workspaceId - for { - parentId, ok := parentById[rootWorkspaceId] - if !ok { - break - } - if cycleStart, seen := lineageIndex[parentId]; seen { - rootWorkspaceId = lineage[cycleStart] - for _, candidate := range lineage[cycleStart:] { - if orderById[candidate] < orderById[rootWorkspaceId] { - rootWorkspaceId = candidate - } - } - break - } - lineageIndex[parentId] = len(lineage) - lineage = append(lineage, parentId) - rootWorkspaceId = parentId - } - - workspaceIds := []string{} - visited := map[string]bool{} - var appendSubtree func(string) - appendSubtree = func(parentId string) { - if visited[parentId] { - return - } - visited[parentId] = true - workspaceIds = append(workspaceIds, parentId) - for _, childId := range orderedIds { - if parentById[childId] == parentId { - appendSubtree(childId) - } - } - } - appendSubtree(rootWorkspaceId) - return workspaceIds -} - -func tmuxDescendantWorkspaceIds(workspaceId string, workspaceItems []map[string]any) []string { - orderedIds := make([]string, 0, len(workspaceItems)) - parentById := map[string]string{} - for _, item := range workspaceItems { - id, _ := item["id"].(string) - if id == "" { - continue - } - orderedIds = append(orderedIds, id) - parentId, _ := item["agent_parent_workspace_id"].(string) - if tmuxIsClaudeTeamWorkspace(item) && parentId != "" { - parentById[id] = parentId - } - } - - descendants := []string{} - visited := map[string]bool{workspaceId: true} - var appendDescendants func(string) - appendDescendants = func(parentId string) { - for _, childId := range orderedIds { - if parentById[childId] != parentId || visited[childId] { - continue - } - visited[childId] = true - appendDescendants(childId) - descendants = append(descendants, childId) - } - } - appendDescendants(workspaceId) - return descendants -} - -func tmuxFinishLiveAgents(rc *rpcContext, workspaceId string) { - payload, err := rc.call("agent.task.list", map[string]any{ - "workspace_id": workspaceId, - "include_finished": false, - }) - if err != nil { - return - } - agents, _ := payload["agents"].([]any) - for _, rawAgent := range agents { - agent, _ := rawAgent.(map[string]any) - agentId, _ := agent["id"].(string) - if agentId == "" { - continue - } - _, _ = rc.call("agent.task.finish", map[string]any{ - "agent_id": agentId, - "state": "cancelled", - }) - } -} - -func tmuxCloseHelperWorkspace(rc *rpcContext, workspaceId string) error { - tmuxFinishLiveAgents(rc, workspaceId) - _, err := rc.call("workspace.close", map[string]any{"workspace_id": workspaceId}) - return err -} - -func tmuxNewSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) - if p.hasFlag("-A") { - return fmt.Errorf("new-session -A is not supported") - } - params := map[string]any{"focus": false} - if cwd := p.value("-c"); cwd != "" { - params["cwd"] = cwd - } - created, err := rc.call("workspace.create", params) - if err != nil { - return err - } - workspaceId, _ := created["workspace_id"].(string) - if workspaceId == "" { - return fmt.Errorf("workspace.create did not return workspace_id") - } - if title := strings.TrimSpace(firstNonEmpty(p.value("-n"), p.value("-s"))); title != "" { - _, _ = rc.call("workspace.rename", map[string]any{"workspace_id": workspaceId, "title": title}) - } - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - if surfaceId, surfaceErr := tmuxGetFirstSurface(rc, workspaceId); surfaceErr == nil { - _, _ = rc.call("surface.send_text", map[string]any{ - "workspace_id": workspaceId, - "surface_id": surfaceId, - "text": text, - }) - } - } - if p.hasFlag("-P") { - ctx, formatErr := tmuxFormatContext(rc, workspaceId, "", "") - if formatErr != nil { - fmt.Printf("@%s\n", workspaceId) - } else { - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+workspaceId)) - } - } - return nil -} - -func tmuxNewWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) - if strings.TrimSpace(p.value("-t")) != "" { - return fmt.Errorf("new-window -t is not supported in programa claude-teams mode") - } - parentWorkspaceId := tmuxResolvedCallerWorkspaceId(rc) - if parentWorkspaceId == "" { - var err error - parentWorkspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return err - } - } - task := strings.TrimSpace(p.value("-n")) - if task == "" { - task = "Helper" - } - params := map[string]any{ - "parent_workspace_id": parentWorkspaceId, - "host": "claude-teams", - "task": task, - "focus": false, - } - if initialCommand := tmuxShellCommandText(p.positional, p.value("-c")); initialCommand != "" { - params["initial_command"] = initialCommand - } - created, err := rc.call("agent.spawn", params) - if err != nil { - return err - } - workspaceId, _ := created["workspace_id"].(string) - if workspaceId == "" { - return fmt.Errorf("agent.spawn did not return workspace_id") - } - if p.hasFlag("-P") { - surfaceId, _ := created["surface_id"].(string) - if surfaceId == "" { - return fmt.Errorf("agent.spawn did not return surface_id") - } - ctx, err := tmuxFormatContext(rc, workspaceId, "", surfaceId) - if err != nil { - fmt.Printf("@%s\n", workspaceId) - return nil - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+workspaceId)) - } - return nil -} - -func tmuxSplitWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-l", "-t"}, []string{"-P", "-b", "-d", "-h", "-v"}) - - targetWs, _, _, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - parentWorkspaceId := tmuxResolvedCallerWorkspaceId(rc) - if parentWorkspaceId == "" { - parentWorkspaceId = targetWs - } - params := map[string]any{ - "parent_workspace_id": parentWorkspaceId, - "host": "claude-teams", - "task": "Helper", - "focus": false, - } - if initialCommand := tmuxShellCommandText(p.positional, p.value("-c")); initialCommand != "" { - params["initial_command"] = initialCommand - } - created, err := rc.call("agent.spawn", params) - if err != nil { - return err - } - workspaceId, _ := created["workspace_id"].(string) - if workspaceId == "" { - return fmt.Errorf("agent.spawn did not return workspace_id") - } - surfaceId, _ := created["surface_id"].(string) - if surfaceId == "" { - return fmt.Errorf("agent.spawn did not return surface_id") - } - - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, workspaceId, "", surfaceId) - if err != nil { - fmt.Println(surfaceId) - return nil - } - fallback := surfaceId - if pid, ok := ctx["pane_id"]; ok { - fallback = pid - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxSelectWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.select", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxSelectPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-P", "-T", "-t"}, nil) - // -P (style) and -T (title) are no-ops - if p.value("-P") != "" || p.value("-T") != "" { - return nil - } - wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("pane.focus", map[string]any{"workspace_id": wsId, "pane_id": paneId}) - return err -} - -func tmuxKillWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - workspaceItems, err := tmuxWorkspaceItems(rc) - if err != nil { - return err - } - for _, descendantId := range tmuxDescendantWorkspaceIds(wsId, workspaceItems) { - if err := tmuxCloseHelperWorkspace(rc, descendantId); err != nil { - return err - } - } - for _, item := range workspaceItems { - itemId, _ := item["id"].(string) - if itemId == wsId && tmuxIsClaudeTeamWorkspace(item) { - return tmuxCloseHelperWorkspace(rc, wsId) - } - } - _, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxKillPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - panes, _ := panePayload["panes"].([]any) - workspaceItems, err := tmuxWorkspaceItems(rc) - if err != nil { - return err - } - teamWorkspace := false - for _, item := range workspaceItems { - itemId, _ := item["id"].(string) - if itemId == wsId { - teamWorkspace = tmuxIsClaudeTeamWorkspace(item) - break - } - } - if len(panes) <= 1 && teamWorkspace { - for _, descendantId := range tmuxDescendantWorkspaceIds(wsId, workspaceItems) { - if err := tmuxCloseHelperWorkspace(rc, descendantId); err != nil { - return err - } - } - return tmuxCloseHelperWorkspace(rc, wsId) - } - _, err = rc.call("surface.close", map[string]any{"workspace_id": wsId, "surface_id": surfId}) - if err == nil { - _, _ = rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"}) - } - return err -} - -func tmuxSendKeys(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, []string{"-l"}) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - text := tmuxSendKeysText(p.positional, p.hasFlag("-l")) - if text != "" { - _, err = rc.call("surface.send_text", map[string]any{ - "workspace_id": wsId, - "surface_id": surfId, - "text": text, - }) - } - return err -} - -func tmuxCapturePane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-E", "-S", "-t"}, []string{"-J", "-N", "-p"}) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - params := map[string]any{ - "workspace_id": wsId, - "surface_id": surfId, - "scrollback": true, - } - if start := p.value("-S"); start != "" { - if lines := parseInt(start); lines < 0 { - params["lines"] = int(math.Abs(float64(lines))) - } - } - payload, err := rc.call("surface.read_text", params) - if err != nil { - return err - } - text, _ := payload["text"].(string) - if p.hasFlag("-p") { - fmt.Print(text) - } else { - store := loadTmuxCompatStore() - store.Buffers["default"] = text - saveTmuxCompatStore(store) - } - return nil -} - -func tmuxDisplayMessage(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, []string{"-p"}) - wsId, paneId, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - ctx, err := tmuxFormatContext(rc, wsId, paneId, surfId) - if err != nil { - ctx = map[string]string{} - } - - // Enrich with geometry - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err == nil { - panes, _ := panePayload["panes"].([]any) - containerFrame, _ := panePayload["container_frame"].(map[string]any) - var matchingPane map[string]any - if paneId != "" { - for _, p := range panes { - pn, _ := p.(map[string]any) - if pid, _ := pn["id"].(string); pid == paneId { - matchingPane = pn - break - } - } - } - if matchingPane == nil { - for _, p := range panes { - pn, _ := p.(map[string]any) - if focused, _ := boolFromAnyGo(pn["focused"]); focused { - matchingPane = pn - break - } - } - } - if matchingPane == nil && len(panes) > 0 { - matchingPane, _ = panes[0].(map[string]any) - } - if matchingPane != nil { - tmuxEnrichContextWithGeometry(ctx, matchingPane, containerFrame) - } - } - - format := p.value("-F") - if len(p.positional) > 0 { - format = strings.Join(p.positional, " ") - } - rendered := tmuxRenderFormat(format, ctx, "") - if p.hasFlag("-p") || rendered != "" { - fmt.Println(rendered) - } - return nil -} - -func tmuxListWindows(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) - items, err := tmuxWorkspaceItems(rc) - if err != nil { - return err - } - for _, item := range items { - wsId, _ := item["id"].(string) - if wsId == "" { - continue - } - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - continue - } - fallback := "" - if idx, ok := ctx["window_index"]; ok { - fallback = idx - } else { - fallback = "?" - } - if name, ok := ctx["window_name"]; ok { - fallback += " " + name - } else { - fallback += " " + wsId - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxListPanes(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) - - target := p.value("-t") - var wsId string - var err error - - if target != "" && tmuxPaneSelector(target) != "" { - wsId, _, err = tmuxResolvePaneTarget(rc, target) - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, target) - } - if err != nil { - return err - } - - workspaceItems, err := tmuxWorkspaceItems(rc) - if err != nil { - return err - } - for _, listedWorkspaceId := range tmuxTeamWorkspaceIds(wsId, workspaceItems) { - payload, err := rc.call("pane.list", map[string]any{"workspace_id": listedWorkspaceId}) - if err != nil { - return err - } - panes, _ := payload["panes"].([]any) - containerFrame, _ := payload["container_frame"].(map[string]any) - - for _, p2 := range panes { - pane, _ := p2.(map[string]any) - if pane == nil { - continue - } - paneId, _ := pane["id"].(string) - if paneId == "" { - continue - } - ctx, err := tmuxFormatContext(rc, listedWorkspaceId, paneId, "") - if err != nil { - continue - } - tmuxEnrichContextWithGeometry(ctx, pane, containerFrame) - fallback := "%" + paneId - if pid, ok := ctx["pane_id"]; ok { - fallback = pid - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - } - return nil -} - -func tmuxRenameWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - title := strings.TrimSpace(strings.Join(p.positional, " ")) - if title == "" { - return fmt.Errorf("rename-window requires a title") - } - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) - return err -} - -func tmuxResizePane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t", "-x", "-y"}, []string{"-D", "-L", "-R", "-U"}) - wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) - if err != nil { - return err - } - - hasDirectional := p.hasFlag("-L") || p.hasFlag("-R") || p.hasFlag("-U") || p.hasFlag("-D") - - if !hasDirectional { - if absWidthStr := p.value("-x"); absWidthStr != "" { - absWidth := parseInt(strings.ReplaceAll(absWidthStr, "%", "")) - // Get current width to compute delta - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - panes, _ := panePayload["panes"].([]any) - for _, pp := range panes { - pane, _ := pp.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == paneId { - cellW := intFromAnyGo(pane["cell_width_px"]) - currentCols := intFromAnyGo(pane["columns"]) - if cellW > 0 && currentCols >= 0 { - delta := absWidth - currentCols - if delta != 0 { - dir := "right" - if delta < 0 { - dir = "left" - delta = -delta - } - rc.call("pane.resize", map[string]any{ - "workspace_id": wsId, - "pane_id": paneId, - "direction": dir, - "amount": delta * cellW, - }) - } - } - break - } - } - return nil - } - } - - if hasDirectional { - dir := "right" - if p.hasFlag("-L") { - dir = "left" - } else if p.hasFlag("-U") { - dir = "up" - } else if p.hasFlag("-D") { - dir = "down" - } - rawAmount := firstNonEmpty(p.value("-x"), p.value("-y"), "5") - rawAmount = strings.ReplaceAll(rawAmount, "%", "") - amount := parseInt(rawAmount) - if amount <= 0 { - amount = 5 - } - _, err := rc.call("pane.resize", map[string]any{ - "workspace_id": wsId, - "pane_id": paneId, - "direction": dir, - "amount": amount, - }) - return err - } - return nil -} - -func tmuxWaitFor(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"--timeout"}, []string{"-S"}) - name := "" - for _, pos := range p.positional { - if !strings.HasPrefix(pos, "-") { - name = pos - break - } - } - if name == "" { - return fmt.Errorf("wait-for requires a name") - } - - socketIdentity := "" - if rc != nil { - socketIdentity = rc.socketPath - } - signalPath, err := tmuxWaitForSignalPath(name, socketIdentity) - if err != nil { - return err - } - - if p.hasFlag("-S") { - if err := writeTmuxWaitForSignal(signalPath); err != nil { - return err - } - fmt.Println("OK") - return nil - } - - // Wait mode: poll for the file - timeoutStr := p.value("--timeout") - timeout := 30.0 - if timeoutStr != "" { - if t := parseFloat(timeoutStr); t > 0 { - timeout = t - } - } - - deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) - for time.Now().Before(deadline) { - if err := validateOwnedPrivateSignal(signalPath); err == nil { - if err := os.Remove(signalPath); err != nil { - return fmt.Errorf("consume wait-for signal: %w", err) - } - return nil - } else if !os.IsNotExist(err) { - return err - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("wait-for timeout: %s", name) -} - -func tmuxLastPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("pane.last", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxHasSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - _, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - return err -} - -func tmuxSelectLayout(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - layoutName := "" - if len(p.positional) > 0 { - layoutName = p.positional[0] - } - - // Resolve workspace from target (may be a pane reference) - var wsId string - var err error - if target := p.value("-t"); target != "" { - if tmuxPaneSelector(target) != "" { - wsId, _, err = tmuxResolvePaneTarget(rc, target) - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, target) - } - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, "") - } - if err != nil { - return err - } - - if layoutName == "main-vertical" || layoutName == "main-horizontal" { - orientation := "vertical" - if layoutName == "main-horizontal" { - orientation = "horizontal" - } - rc.call("workspace.equalize_splits", map[string]any{ - "workspace_id": wsId, - "orientation": orientation, - }) - } else { - rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId}) - } - - return nil -} - -func tmuxShowBuffer(args []string) error { - p := parseTmuxArgs(args, []string{"-b"}, nil) - name := p.value("-b") - if name == "" { - name = "default" - } - store := loadTmuxCompatStore() - if buf, ok := store.Buffers[name]; ok { - fmt.Print(buf) - } - return nil -} - -func tmuxSaveBuffer(args []string) error { - p := parseTmuxArgs(args, []string{"-b"}, nil) - name := p.value("-b") - if name == "" { - name = "default" - } - store := loadTmuxCompatStore() - buf, ok := store.Buffers[name] - if !ok { - return fmt.Errorf("buffer not found: %s", name) - } - if len(p.positional) > 0 { - outputPath := strings.TrimSpace(p.positional[len(p.positional)-1]) - if outputPath != "" { - return os.WriteFile(outputPath, []byte(buf), 0644) - } - } - fmt.Print(buf) - return nil -} diff --git a/daemon/remote/cmd/programad-remote/tmux_compat.go b/daemon/remote/cmd/programad-remote/tmux_compat.go deleted file mode 100644 index 5612913f..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_compat.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" -) - -// runTmuxCompat handles `programa __tmux-compat <args...>`, translating tmux -// commands into programa JSON-RPC calls over the relay socket. -func runTmuxCompat(socketPath string, args []string, refreshAddr func() string) int { - command, cmdArgs, err := splitTmuxCmd(args) - if err != nil { - fmt.Fprintf(os.Stderr, "programa __tmux-compat: %v\n", err) - return 1 - } - - rc := &rpcContext{socketPath: socketPath, refreshAddr: refreshAddr} - if err := dispatchTmuxCommand(rc, command, cmdArgs); err != nil { - fmt.Fprintf(os.Stderr, "programa __tmux-compat: %v\n", err) - return 1 - } - return 0 -} - -// rpcContext holds connection info for making JSON-RPC calls. -type rpcContext struct { - socketPath string - refreshAddr func() string -} - -// call makes a JSON-RPC call and returns the parsed result. -func (rc *rpcContext) call(method string, params map[string]any) (map[string]any, error) { - resp, err := socketRoundTripV2(rc.socketPath, method, params, rc.refreshAddr) - if err != nil { - return nil, err - } - var result map[string]any - if err := json.Unmarshal([]byte(resp), &result); err != nil { - // Some responses are bare values (string, null) - return nil, nil - } - return result, nil -} - -// --- Main dispatch --- -// -// The rest of the tmux-compat shim is split by section across this -// package: tmux_args.go (argument parsing), tmux_format.go (format -// string rendering/context), tmux_target.go (session/window/pane target -// resolution), tmux_store.go (local JSON state), tmux_keys.go (special -// key translation), tmux_waitfor.go (wait-for signaling path), tmux_commands.go -// (per-command implementations), and tmux_helpers.go (small shared helpers). - -func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { - switch command { - case "-v", "-V": - fmt.Println("tmux 3.4") - return nil - - case "new-session", "new": - return tmuxNewSession(rc, args) - case "new-window", "neww": - return tmuxNewWindow(rc, args) - case "split-window", "splitw": - return tmuxSplitWindow(rc, args) - case "select-window", "selectw": - return tmuxSelectWindow(rc, args) - case "select-pane", "selectp": - return tmuxSelectPane(rc, args) - case "kill-window", "killw": - return tmuxKillWindow(rc, args) - case "kill-pane", "killp": - return tmuxKillPane(rc, args) - case "send-keys", "send": - return tmuxSendKeys(rc, args) - case "capture-pane", "capturep": - return tmuxCapturePane(rc, args) - case "display-message", "display", "displayp": - return tmuxDisplayMessage(rc, args) - case "list-windows", "lsw": - return tmuxListWindows(rc, args) - case "list-panes", "lsp": - return tmuxListPanes(rc, args) - case "rename-window", "renamew": - return tmuxRenameWindow(rc, args) - case "resize-pane", "resizep": - return tmuxResizePane(rc, args) - case "wait-for": - return tmuxWaitFor(rc, args) - case "last-pane": - return tmuxLastPane(rc, args) - case "has-session", "has": - return tmuxHasSession(rc, args) - case "select-layout": - return tmuxSelectLayout(rc, args) - case "show-buffer", "showb": - return tmuxShowBuffer(args) - case "save-buffer", "saveb": - return tmuxSaveBuffer(args) - - // No-ops - case "set-option", "set", "set-window-option", "setw", "source-file", - "refresh-client", "attach-session", "detach-client", - "last-window", "next-window", "previous-window", - "set-hook", "set-buffer", "list-buffers": - return nil - - default: - return fmt.Errorf("unsupported tmux command: %s", command) - } -} diff --git a/daemon/remote/cmd/programad-remote/tmux_compat_test.go b/daemon/remote/cmd/programad-remote/tmux_compat_test.go deleted file mode 100644 index 77258c2b..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_compat_test.go +++ /dev/null @@ -1,1104 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "net" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "testing" - "time" -) - -func TestSplitTmuxCmd(t *testing.T) { - tests := []struct { - name string - args []string - wantCmd string - wantN int // expected number of remaining args - }{ - {"simple", []string{"list-panes", "-t", "%abc"}, "list-panes", 2}, - {"version flag", []string{"-V"}, "-V", 0}, - {"with global flags", []string{"-L", "foo", "split-window", "-h"}, "split-window", 1}, - {"case insensitive", []string{"Display-Message", "-p"}, "display-message", 1}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd, args, err := splitTmuxCmd(tt.args) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cmd != tt.wantCmd { - t.Errorf("command = %q, want %q", cmd, tt.wantCmd) - } - if len(args) != tt.wantN { - t.Errorf("args count = %d, want %d", len(args), tt.wantN) - } - }) - } -} - -func TestParseTmuxArgs(t *testing.T) { - p := parseTmuxArgs( - []string{"-dP", "-t", "%abc", "-F", "#{pane_id}", "shell", "cmd"}, - []string{"-t", "-F"}, - []string{"-d", "-P"}, - ) - if !p.hasFlag("-d") { - t.Error("expected -d flag") - } - if !p.hasFlag("-P") { - t.Error("expected -P flag") - } - if p.value("-t") != "%abc" { - t.Errorf("target = %q, want %%abc", p.value("-t")) - } - if p.value("-F") != "#{pane_id}" { - t.Errorf("format = %q, want #{pane_id}", p.value("-F")) - } - if len(p.positional) != 2 || p.positional[0] != "shell" { - t.Errorf("positional = %v, want [shell cmd]", p.positional) - } -} - -func TestParseTmuxArgsClusteredValueFlag(t *testing.T) { - // -t%abc should parse -t with value "%abc" - p := parseTmuxArgs([]string{"-t%abc"}, []string{"-t"}, nil) - if p.value("-t") != "%abc" { - t.Errorf("target = %q, want %%abc", p.value("-t")) - } -} - -func TestTmuxRenderFormat(t *testing.T) { - ctx := map[string]string{ - "pane_id": "%abc123", - "pane_width": "80", - "window_id": "@ws1", - } - - tests := []struct { - format string - fallback string - want string - }{ - {"#{pane_id}", "fallback", "%abc123"}, - {"#{pane_id}:#{pane_width}", "", "%abc123:80"}, - {"#{unknown_var}", "fallback", "fallback"}, - {"", "fallback", "fallback"}, - {"#{pane_id} #{pane_width} #{window_id}", "", "%abc123 80 @ws1"}, - } - for _, tt := range tests { - got := tmuxRenderFormat(tt.format, ctx, tt.fallback) - if got != tt.want { - t.Errorf("tmuxRenderFormat(%q) = %q, want %q", tt.format, got, tt.want) - } - } -} - -func TestTmuxSendKeysText(t *testing.T) { - tests := []struct { - name string - tokens []string - literal bool - want string - }{ - {"literal", []string{"hello", "world"}, true, "hello world"}, - {"special enter", []string{"echo", "hello", "Enter"}, false, "echo hello\r"}, - {"special ctrl-c", []string{"C-c"}, false, "\x03"}, - {"mixed", []string{"ls", "-la", "Enter"}, false, "ls -la\r"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tmuxSendKeysText(tt.tokens, tt.literal) - if got != tt.want { - t.Errorf("got %q, want %q", got, tt.want) - } - }) - } -} - -func TestTmuxShellCommandText(t *testing.T) { - tests := []struct { - positional []string - cwd string - want string - }{ - {[]string{"echo hi"}, "", "echo hi\r"}, - {nil, "/tmp", "cd -- '/tmp'\r"}, - {[]string{"make"}, "/home/user", "cd -- '/home/user' && make\r"}, - {nil, "", ""}, - } - for _, tt := range tests { - got := tmuxShellCommandText(tt.positional, tt.cwd) - if got != tt.want { - t.Errorf("tmuxShellCommandText(%v, %q) = %q, want %q", tt.positional, tt.cwd, got, tt.want) - } - } -} - -func TestTmuxWaitForSignalPath(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_RUNTIME_DIR", "") - path, err := tmuxWaitForSignalPath("test-signal", "/tmp/programa-a.sock") - if err != nil { - t.Fatalf("tmuxWaitForSignalPath: %v", err) - } - if !strings.HasPrefix(path, filepath.Join(home, ".programa", "run", "wait-for")+string(os.PathSeparator)) { - t.Errorf("unexpected path prefix: %s", path) - } - if !strings.HasSuffix(path, ".sig") { - t.Errorf("unexpected path suffix: %s", path) - } -} - -func TestTmuxCompatStoreRoundTrip(t *testing.T) { - // Use a temp dir for the store - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - storePath := filepath.Join(tmpDir, ".programaterm", "tmux-compat-store.json") - if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil { - t.Fatalf("mkdir store dir: %v", err) - } - if err := os.WriteFile(storePath, []byte(`{ - "buffers":{"test":"captured text"}, - "hooks":{"after-new-window":"display-message ready"}, - "mainVerticalLayouts":{"ws1":{"mainSurfaceId":"surface-main"}}, - "lastSplitSurface":{"ws1":"surface-col"} - }`), 0o644); err != nil { - t.Fatalf("write legacy store: %v", err) - } - - store := loadTmuxCompatStore() - store.Buffers["next"] = "more text" - if err := saveTmuxCompatStore(store); err != nil { - t.Fatalf("save: %v", err) - } - - loaded := loadTmuxCompatStore() - if loaded.Buffers["test"] != "captured text" { - t.Errorf("buffer = %q, want %q", loaded.Buffers["test"], "captured text") - } - if loaded.Hooks["after-new-window"] != "display-message ready" { - t.Errorf("hook = %q, want saved hook", loaded.Hooks["after-new-window"]) - } - if loaded.Buffers["next"] != "more text" { - t.Errorf("next buffer = %q, want %q", loaded.Buffers["next"], "more text") - } - saved, err := os.ReadFile(storePath) - if err != nil { - t.Fatalf("read saved store: %v", err) - } - if strings.Contains(string(saved), "mainVerticalLayouts") || strings.Contains(string(saved), "lastSplitSurface") { - t.Fatalf("saved store retained obsolete layout state: %s", saved) - } -} - -func TestTmuxVersion(t *testing.T) { - output := captureStdout(t, func() { - dispatchTmuxCommand(nil, "-v", nil) - }) - if strings.TrimSpace(output) != "tmux 3.4" { - t.Errorf("version = %q, want %q", strings.TrimSpace(output), "tmux 3.4") - } -} - -func TestTmuxDisplayReporterFormatFields(t *testing.T) { - origHome := os.Getenv("HOME") - origWorkspace := os.Getenv("PROGRAMA_WORKSPACE_ID") - origSurface := os.Getenv("PROGRAMA_SURFACE_ID") - origPane := os.Getenv("TMUX_PANE") - os.Setenv("HOME", t.TempDir()) - os.Setenv("PROGRAMA_WORKSPACE_ID", "workspace:1") - os.Setenv("PROGRAMA_SURFACE_ID", "surface:1") - leaderPaneToken := "%" + tmuxStableNumericId(tmuxLeaderPane) - os.Setenv("TMUX_PANE", leaderPaneToken) - defer func() { - os.Setenv("HOME", origHome) - if origWorkspace != "" { - os.Setenv("PROGRAMA_WORKSPACE_ID", origWorkspace) - } else { - os.Unsetenv("PROGRAMA_WORKSPACE_ID") - } - if origSurface != "" { - os.Setenv("PROGRAMA_SURFACE_ID", origSurface) - } else { - os.Unsetenv("PROGRAMA_SURFACE_ID") - } - if origPane != "" { - os.Setenv("TMUX_PANE", origPane) - } else { - os.Unsetenv("TMUX_PANE") - } - }() - - sockPath, _, _ := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - fields := []string{ - "session_id", - "session_name", - "window_index", - "window_id", - "pane_id", - "pane_width", - "pane_height", - "window_width", - "window_height", - "pane_current_path", - "pane_active", - "window_active", - "session_attached", - } - parts := make([]string, 0, len(fields)) - for _, field := range fields { - parts = append(parts, field+"=#{"+field+"}") - } - - output := captureStdout(t, func() { - if err := dispatchTmuxCommand(rc, "display-message", []string{ - "-p", - "-F", strings.Join(parts, "\t"), - "-t", leaderPaneToken, - }); err != nil { - t.Fatalf("display-message: %v", err) - } - }) - - values := map[string]string{} - for _, part := range strings.Split(strings.TrimSpace(output), "\t") { - key, value, ok := strings.Cut(part, "=") - if !ok { - t.Fatalf("malformed field %q in output %q", part, output) - } - values[key] = value - } - for _, field := range fields { - if _, ok := values[field]; !ok { - t.Fatalf("missing field %q in output %q", field, output) - } - } - - assertTmuxFieldMatch(t, values["session_id"], `^\$[0-9]+$`, "session_id") - if values["session_name"] != "programa" { - t.Fatalf("session_name = %q, want programa", values["session_name"]) - } - assertTmuxFieldMatch(t, values["window_index"], `^[0-9]+$`, "window_index") - assertTmuxFieldMatch(t, values["window_id"], `^@[0-9]+$`, "window_id") - assertTmuxFieldMatch(t, values["pane_id"], `^%[0-9]+$`, "pane_id") - assertTmuxFieldMatch(t, values["pane_width"], `^[0-9]+$`, "pane_width") - assertTmuxFieldMatch(t, values["pane_height"], `^[0-9]+$`, "pane_height") - assertTmuxFieldMatch(t, values["window_width"], `^[0-9]+$`, "window_width") - assertTmuxFieldMatch(t, values["window_height"], `^[0-9]+$`, "window_height") - if !filepath.IsAbs(values["pane_current_path"]) { - t.Fatalf("pane_current_path = %q, want an absolute path", values["pane_current_path"]) - } - if values["pane_active"] != "1" { - t.Fatalf("pane_active = %q, want 1 for stringy focused metadata", values["pane_active"]) - } - assertTmuxFieldMatch(t, values["pane_active"], `^[01]$`, "pane_active") - assertTmuxFieldMatch(t, values["window_active"], `^[01]$`, "window_active") - assertTmuxFieldMatch(t, values["session_attached"], `^[01]$`, "session_attached") -} - -func assertTmuxFieldMatch(t *testing.T, got string, pattern string, field string) { - t.Helper() - if !regexp.MustCompile(pattern).MatchString(got) { - t.Fatalf("%s = %q, want match %s", field, got, pattern) - } -} - -func TestGetFocusedContextCanonicalizesPaneRef(t *testing.T) { - sockPath, _, _ := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - - focused := getFocusedContext(rc) - if focused == nil { - t.Fatal("getFocusedContext returned nil") - } - if focused.paneHandle != "pane:1" { - t.Fatalf("paneHandle = %q, want pane:1", focused.paneHandle) - } - if focused.paneId != tmuxLeaderPane { - t.Fatalf("paneId = %q, want canonical pane UUID", focused.paneId) - } -} - -func TestGetFocusedContextKeepsBaseContextWhenCanonicalizationTimesOut(t *testing.T) { - sockPath := startSlowFocusedCanonicalizationSocket(t, 200*time.Millisecond) - rc := &rpcContext{socketPath: sockPath} - - focused := getFocusedContextWithTimeout(rc, 50*time.Millisecond) - if focused == nil { - t.Fatal("getFocusedContextWithTimeout returned nil") - } - if focused.workspaceId != "11111111-1111-4111-8111-111111111111" { - t.Fatalf("workspaceId = %q", focused.workspaceId) - } - if focused.paneHandle != "pane:1" { - t.Fatalf("paneHandle = %q, want pane:1", focused.paneHandle) - } - if focused.paneId != "pane:1" { - t.Fatalf("paneId = %q, want base pane id when canonicalization times out", focused.paneId) - } -} - -func TestTmuxSigiledSelectorsSkipRefsAndIndexes(t *testing.T) { - sockPath, _, _ := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - workspaceId := tmuxLeaderWorkspace - paneId := tmuxLeaderPane - - if got, err := tmuxResolveWorkspaceId(rc, "1"); err != nil || got != workspaceId { - t.Fatalf("unsigiled workspace index resolved to %q, %v; want %s", got, err, workspaceId) - } - if got, err := tmuxCanonicalPaneId(rc, "1", workspaceId); err != nil || got != paneId { - t.Fatalf("unsigiled pane index resolved to %q, %v; want %s", got, err, paneId) - } - if _, err := tmuxResolveWorkspaceId(rc, "$1"); err == nil { - t.Fatal("sigiled workspace selector $1 resolved by index; want no match") - } - if _, err := tmuxCanonicalPaneId(rc, "%1", workspaceId); err == nil { - t.Fatal("sigiled pane selector %1 resolved by index; want no match") - } - if got, err := tmuxResolveWorkspaceId(rc, "$"+tmuxStableNumericId(workspaceId)); err != nil || got != workspaceId { - t.Fatalf("sigiled workspace numeric id resolved to %q, %v; want %s", got, err, workspaceId) - } - if got, err := tmuxCanonicalPaneId(rc, "%"+tmuxStableNumericId(paneId), workspaceId); err != nil || got != paneId { - t.Fatalf("sigiled pane numeric id resolved to %q, %v; want %s", got, err, paneId) - } -} - -func TestTmuxResolveWorkspaceIdAcceptsSigiledUUIDWithoutList(t *testing.T) { - workspaceId := "11111111-1111-4111-8111-111111111111" - rc := &rpcContext{socketPath: filepath.Join(t.TempDir(), "missing.sock")} - - for _, raw := range []string{"$" + workspaceId, "@" + workspaceId} { - got, err := tmuxResolveWorkspaceId(rc, raw) - if err != nil { - t.Fatalf("tmuxResolveWorkspaceId(%q) returned error: %v", raw, err) - } - if got != workspaceId { - t.Fatalf("tmuxResolveWorkspaceId(%q) = %q, want %s", raw, got, workspaceId) - } - } -} - -func TestTmuxCanonicalSelectorsPreferRefsBeforeIndexFallback(t *testing.T) { - sockPath := startMockTmuxSelectorPrioritySocket(t) - rc := &rpcContext{socketPath: sockPath} - workspaceId := "11111111-1111-4111-8111-111111111111" - refPaneId := "33333333-3333-4333-8333-333333333333" - refSurfaceId := "55555555-5555-4555-8555-555555555555" - - if got, err := tmuxCanonicalPaneId(rc, "1", workspaceId); err != nil || got != refPaneId { - t.Fatalf("pane selector resolved to %q, %v; want ref match %s before index fallback", got, err, refPaneId) - } - if got, err := tmuxCanonicalSurfaceId(rc, "1", workspaceId); err != nil || got != refSurfaceId { - t.Fatalf("surface selector resolved to %q, %v; want ref match %s before index fallback", got, err, refSurfaceId) - } -} - -func startMockTmuxSelectorPrioritySocket(t *testing.T) string { - t.Helper() - sockPath := makeShortUnixSocketPath(t) - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - reader := bufio.NewReader(conn) - line, err := reader.ReadBytes('\n') - if err != nil { - return - } - - var req map[string]any - if err := json.Unmarshal(line, &req); err != nil { - _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) - return - } - - method, _ := req["method"].(string) - resp := map[string]any{ - "id": req["id"], - "ok": true, - } - switch method { - case "pane.list": - resp["result"] = map[string]any{ - "panes": []map[string]any{ - {"id": "22222222-2222-4222-8222-222222222222", "ref": "pane:index", "index": 1}, - {"id": "33333333-3333-4333-8333-333333333333", "ref": "1", "index": 2}, - }, - } - case "surface.list": - resp["result"] = map[string]any{ - "surfaces": []map[string]any{ - {"id": "44444444-4444-4444-8444-444444444444", "ref": "surface:index", "index": 1}, - {"id": "55555555-5555-4555-8555-555555555555", "ref": "1", "index": 2}, - }, - } - default: - resp["result"] = map[string]any{} - } - - data, _ := json.Marshal(resp) - _, _ = conn.Write(append(data, '\n')) - }(conn) - } - }() - - return sockPath -} - -func startSlowFocusedCanonicalizationSocket(t *testing.T, delay time.Duration) string { - t.Helper() - sockPath := makeShortUnixSocketPath(t) - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - reader := bufio.NewReader(conn) - line, err := reader.ReadBytes('\n') - if err != nil { - return - } - - var req map[string]any - if err := json.Unmarshal(line, &req); err != nil { - _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) - return - } - - method, _ := req["method"].(string) - resp := map[string]any{ - "id": req["id"], - "ok": true, - } - switch method { - case "system.identify": - resp["result"] = map[string]any{ - "focused": map[string]any{ - "workspace_id": "11111111-1111-4111-8111-111111111111", - "pane_id": "pane:1", - "pane_ref": "pane:1", - "surface_ref": "surface:1", - }, - } - case "pane.list": - time.Sleep(delay) - resp["result"] = map[string]any{ - "panes": []map[string]any{{ - "id": "33333333-3333-4333-8333-333333333333", - "ref": "pane:1", - "index": 1, - }}, - } - default: - resp["result"] = map[string]any{} - } - - data, _ := json.Marshal(resp) - _, _ = conn.Write(append(data, '\n')) - }(conn) - } - }() - - return sockPath -} - -func TestTmuxNoOps(t *testing.T) { - noOps := []string{ - "set-option", "set", "set-window-option", "setw", - "source-file", "refresh-client", "attach-session", "detach-client", - "last-window", "next-window", "previous-window", - "set-hook", "set-buffer", "list-buffers", - } - for _, cmd := range noOps { - t.Run(cmd, func(t *testing.T) { - if err := dispatchTmuxCommand(nil, cmd, nil); err != nil { - t.Errorf("no-op %q returned error: %v", cmd, err) - } - }) - } -} - -func TestTmuxUnsupportedCommand(t *testing.T) { - err := dispatchTmuxCommand(nil, "some-unknown-cmd", nil) - if err == nil { - t.Error("expected error for unknown command") - } - if !strings.Contains(err.Error(), "unsupported") { - t.Errorf("error = %q, want to contain 'unsupported'", err.Error()) - } -} - -func TestIsUUIDish(t *testing.T) { - if !isUUIDish("D88CE676-0A95-4DDA-AD94-E535B0D966DF") { - t.Error("expected UUID to be detected") - } - if !isUUIDish("d88ce676-0a95-4dda-ad94-e535b0d966df") { - t.Error("expected lowercase UUID to be detected") - } - if isUUIDish("not-a-uuid") { - t.Error("expected non-UUID to be rejected") - } -} - -func TestTmuxPaneSelector(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"%abc123", "%abc123"}, - {"pane:test", "pane:test"}, - {"@ws1.%pane2", "%pane2"}, - {"@ws1", ""}, - {"", ""}, - } - for _, tt := range tests { - got := tmuxPaneSelector(tt.input) - if got != tt.want { - t.Errorf("tmuxPaneSelector(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestTmuxWindowSelector(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"%abc123", ""}, - {"pane:test", ""}, - {"@ws1.%pane2", "@ws1"}, - {"@ws1", "@ws1"}, - {"", ""}, - } - for _, tt := range tests { - got := tmuxWindowSelector(tt.input) - if got != tt.want { - t.Errorf("tmuxWindowSelector(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestCreateTmuxShimDir(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - dir, err := createTmuxShimDir("test-shim-bin", claudeTeamsShimScript) - if err != nil { - t.Fatalf("createTmuxShimDir: %v", err) - } - tmuxPath := filepath.Join(dir, "tmux") - info, err := os.Stat(tmuxPath) - if err != nil { - t.Fatalf("tmux shim not found: %v", err) - } - if info.Mode()&0111 == 0 { - t.Error("tmux shim is not executable") - } - content, _ := os.ReadFile(tmuxPath) - if !strings.Contains(string(content), "__tmux-compat") { - t.Error("shim script should reference __tmux-compat") - } -} - -func TestCreateTmuxShimDirUsesProgramaHome(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - dir, err := createTmuxShimDir("test-shim-bin", claudeTeamsShimScript) - if err != nil { - t.Fatalf("createTmuxShimDir: %v", err) - } - - want := filepath.Join(home, ".programa", "test-shim-bin") - if dir != want { - t.Fatalf("createTmuxShimDir returned %q, want Programa-owned path %q", dir, want) - } -} - -func TestCreateOMOShimDir(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - dir, err := createOMOShimDir() - if err != nil { - t.Fatalf("createOMOShimDir: %v", err) - } - // Check tmux shim exists - tmuxPath := filepath.Join(dir, "tmux") - if _, err := os.Stat(tmuxPath); err != nil { - t.Fatalf("tmux shim not found: %v", err) - } - // Check terminal-notifier shim exists - notifierPath := filepath.Join(dir, "terminal-notifier") - if _, err := os.Stat(notifierPath); err != nil { - t.Fatalf("terminal-notifier shim not found: %v", err) - } -} - -func TestConfigureAgentEnvironment(t *testing.T) { - // Save and restore env vars - envKeys := []string{ - "PROGRAMA_CLAUDE_TEAMS_PROGRAMA_BIN", "PATH", "TMUX", "TMUX_PANE", - "TERM", "PROGRAMA_SOCKET_PATH", "PROGRAMA_SOCKET", "TERM_PROGRAM", - "PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "COLORTERM", - } - saved := make(map[string]string) - for _, k := range envKeys { - saved[k] = os.Getenv(k) - } - defer func() { - for k, v := range saved { - if v != "" { - os.Setenv(k, v) - } else { - os.Unsetenv(k) - } - } - }() - - os.Setenv("TERM_PROGRAM", "should-be-removed") - - configureAgentEnvironment(agentConfig{ - shimDir: "/tmp/test-shim", - socketPath: "127.0.0.1:54321", - focused: &focusedContext{ - workspaceId: "ws-abc", - windowId: "win-123", - paneHandle: "pane:456", - paneId: "pane-456", - surfaceId: "surf-789", - }, - tmuxPathPrefix: "programa-claude-teams", - cmuxBinEnvVar: "PROGRAMA_CLAUDE_TEAMS_PROGRAMA_BIN", - termEnvVar: "PROGRAMA_CLAUDE_TEAMS_TERM", - extraEnv: map[string]string{ - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - }, - }) - - // Verify PATH was prepended - if !strings.HasPrefix(os.Getenv("PATH"), "/tmp/test-shim:") { - t.Error("PATH should start with shim dir") - } - // Verify TMUX is set with focused context - tmux := os.Getenv("TMUX") - if !strings.Contains(tmux, "ws-abc") { - t.Errorf("TMUX = %q, should contain workspace ID", tmux) - } - // Verify TMUX_PANE - wantPane := "%" + tmuxStableNumericId("pane-456") - if os.Getenv("TMUX_PANE") != wantPane { - t.Errorf("TMUX_PANE = %q, want %s", os.Getenv("TMUX_PANE"), wantPane) - } - // Verify socket path - if os.Getenv("PROGRAMA_SOCKET_PATH") != "127.0.0.1:54321" { - t.Errorf("PROGRAMA_SOCKET_PATH = %q", os.Getenv("PROGRAMA_SOCKET_PATH")) - } - // Verify COLORTERM is set for truecolor support - if os.Getenv("COLORTERM") != "truecolor" { - t.Errorf("COLORTERM = %q, want truecolor", os.Getenv("COLORTERM")) - } - // Verify workspace/surface IDs - if os.Getenv("PROGRAMA_WORKSPACE_ID") != "ws-abc" { - t.Errorf("PROGRAMA_WORKSPACE_ID = %q", os.Getenv("PROGRAMA_WORKSPACE_ID")) - } - if os.Getenv("PROGRAMA_SURFACE_ID") != "surf-789" { - t.Errorf("PROGRAMA_SURFACE_ID = %q", os.Getenv("PROGRAMA_SURFACE_ID")) - } - // Verify extra env - if os.Getenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS") != "1" { - t.Error("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS should be 1") - } -} - -func TestClaudeTeamsLaunchArgs(t *testing.T) { - // Should prepend --teammate-mode auto - args := claudeTeamsLaunchArgs([]string{"--verbose"}) - if args[0] != "--teammate-mode" || args[1] != "auto" || args[2] != "--verbose" { - t.Errorf("args = %v, want [--teammate-mode auto --verbose]", args) - } - - // Should not duplicate if already present - args = claudeTeamsLaunchArgs([]string{"--teammate-mode", "off"}) - if args[0] != "--teammate-mode" || args[1] != "off" { - t.Errorf("args = %v, should not prepend when already present", args) - } -} - -func TestOMOLaunchArgsAvoidsOccupiedEnvironmentPort(t *testing.T) { - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("reserve occupied loopback port: %v", err) - } - defer listener.Close() - - occupiedPort := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) - t.Setenv("OPENCODE_PORT", occupiedPort) - - args := omoLaunchArgs(nil) - if len(args) != 2 || args[0] != "--port" { - t.Fatalf("omoLaunchArgs(nil) = %v, want injected --port <available-port>", args) - } - if args[1] == occupiedPort { - t.Fatalf("omoLaunchArgs selected occupied OPENCODE_PORT %s", occupiedPort) - } - if args[1] == "" || args[1] == "0" { - t.Fatalf("omoLaunchArgs selected invalid port %q", args[1]) - } - if got := os.Getenv("OPENCODE_PORT"); got != args[1] { - t.Fatalf("OPENCODE_PORT = %q, want selected launch port %q", got, args[1]) - } -} - -func TestOMOLaunchArgsPreservesExplicitOccupiedPort(t *testing.T) { - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Fatalf("reserve occupied loopback port: %v", err) - } - defer listener.Close() - - occupiedPort := strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) - t.Setenv("OPENCODE_PORT", "4096") - explicit := "--port=" + occupiedPort - - args := omoLaunchArgs([]string{explicit, "--verbose"}) - if len(args) != 2 || args[0] != explicit || args[1] != "--verbose" { - t.Fatalf("omoLaunchArgs changed explicit caller port: %v", args) - } -} - -func TestOMOPluginShadowOwnsPackageState(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - userDir := filepath.Join(home, ".config", "opencode") - userNodeModules := filepath.Join(userDir, "node_modules") - pluginDir := filepath.Join(userNodeModules, omoPluginName) - if err := os.MkdirAll(pluginDir, 0755); err != nil { - t.Fatalf("create installed plugin fixture: %v", err) - } - - userConfig := []byte("{}\n") - userPackage := []byte("{\"dependencies\":{\"stale-package\":\"0.0.1\"}}\n") - userLock := []byte("stale-user-lock\n") - if err := os.WriteFile(filepath.Join(userDir, "opencode.json"), userConfig, 0644); err != nil { - t.Fatalf("write user opencode config: %v", err) - } - if err := os.WriteFile(filepath.Join(userDir, "package.json"), userPackage, 0644); err != nil { - t.Fatalf("write user package manifest: %v", err) - } - if err := os.WriteFile(filepath.Join(userDir, "bun.lock"), userLock, 0644); err != nil { - t.Fatalf("write user lockfile: %v", err) - } - - if err := omoEnsurePlugin(os.Getenv("PATH")); err != nil { - t.Fatalf("omoEnsurePlugin: %v", err) - } - - shadowDir := omoShadowConfigDir() - shadowPackagePath := filepath.Join(shadowDir, "package.json") - shadowPackageInfo, err := os.Lstat(shadowPackagePath) - if err != nil { - t.Fatalf("stat shadow package manifest: %v", err) - } - if shadowPackageInfo.Mode()&os.ModeSymlink != 0 { - t.Fatalf("shadow package manifest must not symlink user package state: %s", shadowPackagePath) - } - if !shadowPackageInfo.Mode().IsRegular() { - t.Fatalf("shadow package manifest is not a regular file: mode=%s", shadowPackageInfo.Mode()) - } - - shadowPackageData, err := os.ReadFile(shadowPackagePath) - if err != nil { - t.Fatalf("read shadow package manifest: %v", err) - } - var shadowPackage map[string]any - if err := json.Unmarshal(shadowPackageData, &shadowPackage); err != nil { - t.Fatalf("decode shadow package manifest: %v", err) - } - if private, _ := shadowPackage["private"].(bool); !private { - t.Fatalf("shadow package manifest must be private: %s", shadowPackageData) - } - dependencies, _ := shadowPackage["dependencies"].(map[string]any) - if got, _ := dependencies[omoPluginName].(string); got != "latest" { - t.Fatalf("shadow package dependency %q = %q, want latest", omoPluginName, got) - } - - shadowLockPath := filepath.Join(shadowDir, "bun.lock") - if info, err := os.Lstat(shadowLockPath); err == nil && info.Mode()&os.ModeSymlink != 0 { - t.Fatalf("shadow lockfile must not symlink user lock state: %s", shadowLockPath) - } else if err != nil && !os.IsNotExist(err) { - t.Fatalf("stat shadow lockfile: %v", err) - } - - gotUserPackage, err := os.ReadFile(filepath.Join(userDir, "package.json")) - if err != nil { - t.Fatalf("read user package manifest after setup: %v", err) - } - if string(gotUserPackage) != string(userPackage) { - t.Fatalf("user package manifest changed: got %q, want %q", gotUserPackage, userPackage) - } - gotUserLock, err := os.ReadFile(filepath.Join(userDir, "bun.lock")) - if err != nil { - t.Fatalf("read user lockfile after setup: %v", err) - } - if string(gotUserLock) != string(userLock) { - t.Fatalf("user lockfile changed: got %q, want %q", gotUserLock, userLock) - } -} - -func TestMergeNodeOptions(t *testing.T) { - const restoreModulePath = "/tmp/restore-node-options.cjs" - - if got := mergeNodeOptions("", restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096" { - t.Fatalf("mergeNodeOptions(\"\") = %q", got) - } - - if got := mergeNodeOptions("--trace-warnings", restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { - t.Fatalf("mergeNodeOptions preserves existing flags = %q", got) - } - - existing := "--max-old-space-size=2048 --trace-warnings" - if got := mergeNodeOptions(existing, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { - t.Fatalf("mergeNodeOptions should replace existing size flag = %q", got) - } - - spaceSeparated := "--max-old-space-size 2048 --trace-warnings" - if got := mergeNodeOptions(spaceSeparated, restoreModulePath); got != "--require=/tmp/restore-node-options.cjs --max-old-space-size=4096 --trace-warnings" { - t.Fatalf("mergeNodeOptions should replace space-separated size flag = %q", got) - } -} - -func TestTmuxWaitForSignalRoundTrip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_RUNTIME_DIR", "") - name := "test-roundtrip-" + randomHex(4) - rc := &rpcContext{socketPath: "/tmp/programa-session-a.sock"} - path, pathErr := tmuxWaitForSignalPath(name, rc.socketPath) - if pathErr != nil { - t.Fatalf("tmuxWaitForSignalPath: %v", pathErr) - } - defer os.Remove(path) - - // Signal creates the file - if err := dispatchTmuxCommand(rc, "wait-for", []string{"-S", name}); err != nil { - t.Fatalf("signal wait-for: %v", err) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("signal file not created: %v", err) - } - - // Wait consumes the file - err := dispatchTmuxCommand(rc, "wait-for", []string{name}) - if err != nil { - t.Fatalf("wait-for should succeed: %v", err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Error("signal file should be removed after wait") - } -} - -func TestTmuxWaitForSignalsAreScopedBySocket(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_RUNTIME_DIR", "") - name := "shared-name" - first, err := tmuxWaitForSignalPath(name, "/tmp/programa-session-a.sock") - if err != nil { - t.Fatalf("first signal path: %v", err) - } - second, err := tmuxWaitForSignalPath(name, "/tmp/programa-session-b.sock") - if err != nil { - t.Fatalf("second signal path: %v", err) - } - if first == second { - t.Fatalf("different socket sessions share wait-for signal path %q", first) - } -} - -func TestTmuxWaitForRejectsSymlinkSignal(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_RUNTIME_DIR", "") - rc := &rpcContext{socketPath: "/tmp/programa-session.sock"} - path, err := tmuxWaitForSignalPath("malicious", rc.socketPath) - if err != nil { - t.Fatalf("signal path: %v", err) - } - target := filepath.Join(t.TempDir(), "target") - if err := os.WriteFile(target, []byte("do not touch"), 0600); err != nil { - t.Fatalf("write target: %v", err) - } - if err := os.Symlink(target, path); err != nil { - t.Fatalf("create signal symlink: %v", err) - } - if err := dispatchTmuxCommand(rc, "wait-for", []string{"-S", "malicious"}); err == nil { - t.Fatal("expected symlinked wait-for signal to be rejected") - } - data, err := os.ReadFile(target) - if err != nil || string(data) != "do not touch" { - t.Fatalf("symlink target changed: data=%q err=%v", data, err) - } -} - -// startMockNewWindowSocket returns a mock relay socket that resolves -// workspace.list to a single parent workspace and records the params -// passed to every agent.spawn call (guarded by a mutex, since the -// listener services requests on their own goroutines). -func startMockNewWindowSocket(t *testing.T) (sockPath string, spawnCalls *[]map[string]any, mu *sync.Mutex) { - t.Helper() - sockPath = makeShortUnixSocketPath(t) - calls := []map[string]any{} - var lock sync.Mutex - - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - t.Cleanup(func() { ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - reader := bufio.NewReader(conn) - line, err := reader.ReadBytes('\n') - if err != nil { - return - } - - var req map[string]any - if err := json.Unmarshal(line, &req); err != nil { - _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) - return - } - - method, _ := req["method"].(string) - params, _ := req["params"].(map[string]any) - resp := map[string]any{"id": req["id"], "ok": true} - - switch method { - case "workspace.list": - resp["result"] = map[string]any{ - "workspaces": []map[string]any{{ - "id": "22222222-2222-4222-8222-222222222222", - "ref": "workspace:9", - "index": 9, - "title": "target-session", - }}, - } - case "agent.spawn": - lock.Lock() - calls = append(calls, params) - lock.Unlock() - resp["result"] = map[string]any{ - "workspace_id": "33333333-3333-4333-8333-333333333333", - "surface_id": "44444444-4444-4444-8444-444444444444", - } - default: - resp["ok"] = false - resp["error"] = map[string]any{"code": "unsupported", "message": method} - } - - payload, _ := json.Marshal(resp) - _, _ = conn.Write(append(payload, '\n')) - }(conn) - } - }() - - return sockPath, &calls, &lock -} - -func TestTmuxNewWindowRejectsTargetSession(t *testing.T) { - sockPath, _, _ := startMockNewWindowSocket(t) - rc := &rpcContext{socketPath: sockPath} - - if err := dispatchTmuxCommand(rc, "new-window", []string{"-t", "target-session"}); err == nil { - t.Fatal("new-window -t should be rejected in claude-teams mode") - } -} - -func TestTmuxNewWindowSpawnsNestedTeamHelper(t *testing.T) { - sockPath, calls, mu := startMockNewWindowSocket(t) - rc := &rpcContext{socketPath: sockPath} - t.Setenv("PROGRAMA_WORKSPACE_ID", "22222222-2222-4222-8222-222222222222") - - if err := dispatchTmuxCommand(rc, "new-window", []string{"-n", "Review", "-c", "/repo", "codex"}); err != nil { - t.Fatalf("new-window: %v", err) - } - - mu.Lock() - defer mu.Unlock() - if len(*calls) != 1 { - t.Fatalf("agent.spawn calls = %d, want 1", len(*calls)) - } - call := (*calls)[0] - if call["parent_workspace_id"] != "22222222-2222-4222-8222-222222222222" { - t.Errorf("parent_workspace_id = %v, want caller workspace", call["parent_workspace_id"]) - } - if call["host"] != "claude-teams" || call["task"] != "Review" || call["focus"] != false { - t.Errorf("agent.spawn helper metadata = %v", call) - } - if call["initial_command"] != "cd -- '/repo' && codex\r" { - t.Errorf("initial_command = %q", call["initial_command"]) - } -} - -func TestTmuxShowBuffer(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - os.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - store := loadTmuxCompatStore() - store.Buffers["default"] = "hello world" - saveTmuxCompatStore(store) - - output := captureStdout(t, func() { - tmuxShowBuffer(nil) - }) - if strings.TrimSpace(output) != "hello world" { - t.Errorf("output = %q, want %q", output, "hello world") - } -} diff --git a/daemon/remote/cmd/programad-remote/tmux_format.go b/daemon/remote/cmd/programad-remote/tmux_format.go deleted file mode 100644 index a00e5fcb..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_format.go +++ /dev/null @@ -1,373 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "hash/fnv" - "os" - "path/filepath" - "regexp" - "strings" -) - -// --- Format string rendering --- - -var tmuxFormatVarRe = regexp.MustCompile(`#\{[^}]+\}`) - -func tmuxRenderFormat(format string, context map[string]string, fallback string) string { - if format == "" { - return fallback - } - rendered := format - for key, value := range context { - rendered = strings.ReplaceAll(rendered, "#{"+key+"}", value) - } - // Remove any remaining unresolved #{...} variables - rendered = tmuxFormatVarRe.ReplaceAllString(rendered, "") - rendered = strings.TrimSpace(rendered) - if rendered == "" { - return fallback - } - return rendered -} - -// --- Format context building --- - -func tmuxFormatContext(rc *rpcContext, workspaceId string, paneId string, surfaceId string) (map[string]string, error) { - canonicalWsId, err := tmuxResolveWorkspaceId(rc, workspaceId) - if err != nil { - return nil, err - } - - ctx := map[string]string{ - "session_name": "programa", - "session_id": "$" + tmuxStableNumericId(canonicalWsId), - "session_attached": "1", - "window_id": "@" + tmuxStableNumericId(canonicalWsId), - "window_uuid": canonicalWsId, - "window_active": "0", - "window_flags": "", - "window_width": "80", - "window_height": "24", - "pane_active": "1", - "pane_width": "80", - "pane_height": "24", - "pane_current_path": tmuxFallbackCurrentPath(), - } - activeWorkspaceId := tmuxActiveWorkspaceId(rc) - activeByCaller := activeWorkspaceId == canonicalWsId - if activeByCaller { - tmuxSetWindowActive(ctx, true) - } - - // Get workspace list for index/title - workspaces, err := tmuxWorkspaceItems(rc) - if err == nil { - for _, ws := range workspaces { - wsId, _ := ws["id"].(string) - wsRef, _ := ws["ref"].(string) - if wsId == canonicalWsId || wsRef == workspaceId { - if active, ok := boolFromAnyGo(ws["active"]); ok && !activeByCaller { - tmuxSetWindowActive(ctx, active) - } else if focused, ok := boolFromAnyGo(ws["focused"]); ok && !activeByCaller { - tmuxSetWindowActive(ctx, focused) - } else if selected, ok := boolFromAnyGo(ws["selected"]); ok && !activeByCaller { - tmuxSetWindowActive(ctx, selected) - } - if idx := intFromAnyGo(ws["index"]); idx >= 0 { - ctx["window_index"] = fmt.Sprintf("%d", idx) - } - if title, _ := ws["title"].(string); strings.TrimSpace(title) != "" { - ctx["window_name"] = strings.TrimSpace(title) - } - if path := tmuxPathFromObject(ws); path != "" { - ctx["pane_current_path"] = path - } - if paneCount := intFromAnyGo(ws["pane_count"]); paneCount >= 0 { - ctx["window_panes"] = fmt.Sprintf("%d", paneCount) - } - break - } - } - } - - // Get current surface info - currentPayload, err := rc.call("surface.current", map[string]any{"workspace_id": canonicalWsId}) - if err != nil { - return ctx, nil - } - - resolvedPaneId := "" - if paneId != "" { - if pid, err := tmuxCanonicalPaneId(rc, paneId, canonicalWsId); err == nil { - resolvedPaneId = pid - } else { - resolvedPaneId = paneId - } - } - if resolvedPaneId == "" { - if pid, ok := currentPayload["pane_id"].(string); ok { - resolvedPaneId = pid - } else if pref, ok := currentPayload["pane_ref"].(string); ok { - if pid, err := tmuxCanonicalPaneId(rc, pref, canonicalWsId); err == nil { - resolvedPaneId = pid - } else { - resolvedPaneId = pref - } - } - } - - resolvedSurfaceId := "" - if surfaceId != "" { - if sid, err := tmuxCanonicalSurfaceId(rc, surfaceId, canonicalWsId); err == nil { - resolvedSurfaceId = sid - } else { - resolvedSurfaceId = surfaceId - } - } - if resolvedSurfaceId == "" && resolvedPaneId != "" { - if sid, err := tmuxSelectedSurfaceId(rc, canonicalWsId, resolvedPaneId); err == nil { - resolvedSurfaceId = sid - } - } - if resolvedSurfaceId == "" { - if sid, ok := currentPayload["surface_id"].(string); ok { - resolvedSurfaceId = sid - } - } - - if resolvedPaneId != "" { - ctx["pane_id"] = "%" + tmuxStableNumericId(resolvedPaneId) - ctx["pane_uuid"] = resolvedPaneId - - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": canonicalWsId}) - if err == nil { - panes, _ := panePayload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == resolvedPaneId { - if idx := intFromAnyGo(pane["index"]); idx >= 0 { - ctx["pane_index"] = fmt.Sprintf("%d", idx) - } - if focused, ok := boolFromAnyGo(pane["focused"]); ok { - if focused { - ctx["pane_active"] = "1" - } else { - ctx["pane_active"] = "0" - } - } - break - } - } - } - } - - if resolvedSurfaceId != "" { - ctx["surface_id"] = resolvedSurfaceId - surfacePayload, err := rc.call("surface.list", map[string]any{"workspace_id": canonicalWsId}) - if err == nil { - surfaces, _ := surfacePayload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if sid, _ := surface["id"].(string); sid == resolvedSurfaceId { - if title, _ := surface["title"].(string); strings.TrimSpace(title) != "" { - ctx["pane_title"] = strings.TrimSpace(title) - if _, ok := ctx["window_name"]; !ok { - ctx["window_name"] = strings.TrimSpace(title) - } - } - if path := tmuxPathFromObject(surface); path != "" { - ctx["pane_current_path"] = path - } - break - } - } - } - } - - return ctx, nil -} - -func tmuxEnrichContextWithGeometry(ctx map[string]string, pane map[string]any, containerFrame map[string]any) { - isFocused, _ := boolFromAnyGo(pane["focused"]) - if isFocused { - ctx["pane_active"] = "1" - } else { - ctx["pane_active"] = "0" - } - - columns := intFromAnyGo(pane["columns"]) - rows := intFromAnyGo(pane["rows"]) - if columns < 0 || rows < 0 { - return - } - ctx["pane_width"] = fmt.Sprintf("%d", columns) - ctx["pane_height"] = fmt.Sprintf("%d", rows) - - cellW := intFromAnyGo(pane["cell_width_px"]) - cellH := intFromAnyGo(pane["cell_height_px"]) - if cellW <= 0 || cellH <= 0 { - return - } - - if frame, ok := pane["pixel_frame"].(map[string]any); ok { - px := floatFromAny(frame["x"]) - py := floatFromAny(frame["y"]) - ctx["pane_left"] = fmt.Sprintf("%d", int(px)/cellW) - ctx["pane_top"] = fmt.Sprintf("%d", int(py)/cellH) - } - - if containerFrame != nil { - cw := floatFromAny(containerFrame["width"]) - ch := floatFromAny(containerFrame["height"]) - ww := int(cw) / cellW - wh := int(ch) / cellH - if ww < 1 { - ww = 1 - } - if wh < 1 { - wh = 1 - } - ctx["window_width"] = fmt.Sprintf("%d", ww) - ctx["window_height"] = fmt.Sprintf("%d", wh) - } -} - -func floatFromAny(v any) float64 { - switch t := v.(type) { - case float64: - return t - case int: - return float64(t) - case json.Number: - f, _ := t.Float64() - return f - } - return 0 -} - -func intFromAnyGo(v any) int { - switch t := v.(type) { - case float64: - return int(t) - case int: - return t - case json.Number: - i, err := t.Int64() - if err != nil { - return -1 - } - return int(i) - } - return -1 -} - -// tmuxStableNumericId hashes a canonical UUID/ref into a small positive -// decimal string so tmux-compat can emit ids that look like real tmux -// numeric ids ($0, @3, %12) instead of leaking the raw UUID or (worse) -// hardcoding the same literal for every session/window/pane. The hash is -// stable across calls for the same input, so a script that reads -// #{pane_id} and later selects on it with -t will resolve back to the -// same pane via tmuxNumericIdMatches. -func tmuxStableNumericId(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - raw = "programa" - } - h := fnv.New64a() - _, _ = h.Write([]byte(raw)) - value := h.Sum64() & 0x7fffffffffffffff - if value == 0 { - value = 1 - } - return fmt.Sprintf("%d", value) -} - -// tmuxSetWindowActive sets window_active/window_flags to reflect real -// focus state instead of the old hardcoded "always active" values. -func tmuxSetWindowActive(ctx map[string]string, active bool) { - if active { - ctx["window_active"] = "1" - ctx["window_flags"] = "*" - } else { - ctx["window_active"] = "0" - ctx["window_flags"] = "" - } -} - -// tmuxNormalizePath expands ~ and resolves relative paths to an absolute, -// cleaned path. Returns "" if it cannot produce an absolute path. -func tmuxNormalizePath(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "~/") || raw == "~" { - if home, err := os.UserHomeDir(); err == nil && home != "" { - if raw == "~" { - raw = home - } else { - raw = filepath.Join(home, raw[2:]) - } - } - } - if !filepath.IsAbs(raw) { - if abs, err := filepath.Abs(raw); err == nil { - raw = abs - } - } - if filepath.IsAbs(raw) { - return filepath.Clean(raw) - } - return "" -} - -func tmuxFirstPath(values ...string) string { - for _, value := range values { - if path := tmuxNormalizePath(value); path != "" { - return path - } - } - return "" -} - -// tmuxPathFromObject pulls a working-directory-ish path off a -// workspace/pane/surface RPC payload item, trying the field names programa -// actually populates. -func tmuxPathFromObject(item map[string]any) string { - if item == nil { - return "" - } - return tmuxFirstPath( - stringFromAnyGo(item["pane_current_path"]), - stringFromAnyGo(item["current_directory"]), - stringFromAnyGo(item["requested_working_directory"]), - stringFromAnyGo(item["working_directory"]), - stringFromAnyGo(item["cwd"]), - ) -} - -// tmuxFallbackCurrentPath returns a best-effort absolute path to use as -// pane_current_path when nothing more specific is available. -func tmuxFallbackCurrentPath() string { - if path := tmuxNormalizePath(os.Getenv("PWD")); path != "" { - return path - } - if cwd, err := os.Getwd(); err == nil { - if path := tmuxNormalizePath(cwd); path != "" { - return path - } - } - if home, err := os.UserHomeDir(); err == nil { - if path := tmuxNormalizePath(home); path != "" { - return path - } - } - return "/" -} diff --git a/daemon/remote/cmd/programad-remote/tmux_helpers.go b/daemon/remote/cmd/programad-remote/tmux_helpers.go deleted file mode 100644 index aaa15ea6..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_helpers.go +++ /dev/null @@ -1,106 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strings" -) - -// --- Helpers --- - -func tmuxGetFirstSurface(rc *rpcContext, workspaceId string) (string, error) { - payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - if len(surfaces) == 0 { - return "", fmt.Errorf("workspace has no surfaces") - } - // Prefer focused surface - for _, s := range surfaces { - surf, _ := s.(map[string]any) - if focused, _ := boolFromAnyGo(surf["focused"]); focused { - if id, _ := surf["id"].(string); id != "" { - return id, nil - } - } - } - if surf, ok := surfaces[0].(map[string]any); ok { - if id, _ := surf["id"].(string); id != "" { - return id, nil - } - } - return "", fmt.Errorf("workspace has no surfaces") -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if v != "" { - return v - } - } - return "" -} - -func parseInt(s string) int { - s = strings.TrimSpace(s) - var n int - fmt.Sscanf(s, "%d", &n) - return n -} - -func parseFloat(s string) float64 { - s = strings.TrimSpace(s) - var f float64 - fmt.Sscanf(s, "%f", &f) - return f -} - -// boolFromAnyGo normalizes RPC-payload boolean fields that may arrive as a -// native bool, a stringy "1"/"true"/"yes"/"on" (or "0"/"false"/"no"/"off"), -// or a numeric 0/1. The second return value reports whether v was -// recognized as a boolean at all, so callers can distinguish "false" from -// "field absent". -func boolFromAnyGo(v any) (bool, bool) { - switch t := v.(type) { - case bool: - return t, true - case string: - switch strings.ToLower(strings.TrimSpace(t)) { - case "1", "true", "yes", "on": - return true, true - case "0", "false", "no", "off": - return false, true - } - case float64: - if t == 0 { - return false, true - } - if t == 1 { - return true, true - } - case int: - if t == 0 { - return false, true - } - if t == 1 { - return true, true - } - case json.Number: - i, err := t.Int64() - if err == nil && (i == 0 || i == 1) { - return i == 1, true - } - } - return false, false -} - -// stringFromAnyGo extracts a trimmed string from an RPC payload field, -// returning "" if the field is missing or not a string. -func stringFromAnyGo(value any) string { - if s, ok := value.(string); ok { - return strings.TrimSpace(s) - } - return "" -} diff --git a/daemon/remote/cmd/programad-remote/tmux_keys.go b/daemon/remote/cmd/programad-remote/tmux_keys.go deleted file mode 100644 index 6aa64ffd..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_keys.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import "strings" - -// --- Special key translation --- - -func tmuxSpecialKeyText(token string) string { - switch strings.ToLower(token) { - case "enter", "c-m", "kpenter": - return "\r" - case "tab", "c-i": - return "\t" - case "space": - return " " - case "bspace", "backspace": - return "\x7f" - case "escape", "esc", "c-[": - return "\x1b" - case "c-c": - return "\x03" - case "c-d": - return "\x04" - case "c-z": - return "\x1a" - case "c-l": - return "\x0c" - default: - return "" - } -} - -func tmuxSendKeysText(tokens []string, literal bool) string { - if literal { - return strings.Join(tokens, " ") - } - var result strings.Builder - pendingSpace := false - for _, token := range tokens { - if special := tmuxSpecialKeyText(token); special != "" { - result.WriteString(special) - pendingSpace = false - continue - } - if pendingSpace { - result.WriteByte(' ') - } - result.WriteString(token) - pendingSpace = true - } - return result.String() -} - -func tmuxShellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" -} - -func tmuxShellCommandText(positional []string, cwd string) string { - cwd = strings.TrimSpace(cwd) - cmd := strings.TrimSpace(strings.Join(positional, " ")) - if cwd == "" && cmd == "" { - return "" - } - var pieces []string - if cwd != "" { - pieces = append(pieces, "cd -- "+tmuxShellQuote(cwd)) - } - if cmd != "" { - pieces = append(pieces, cmd) - } - return strings.Join(pieces, " && ") + "\r" -} diff --git a/daemon/remote/cmd/programad-remote/tmux_split_ref_test.go b/daemon/remote/cmd/programad-remote/tmux_split_ref_test.go deleted file mode 100644 index c7ac007d..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_split_ref_test.go +++ /dev/null @@ -1,337 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "net" - "sync" - "testing" -) - -const ( - tmuxLeaderWorkspace = "11111111-1111-4111-8111-111111111111" - tmuxTeamWorkspaceA = "22222222-2222-4222-8222-222222222222" - tmuxOtherWorkspace = "33333333-3333-4333-8333-333333333333" - tmuxTeamWorkspaceB = "44444444-4444-4444-8444-444444444444" - tmuxSpawnWorkspace = "55555555-5555-4555-8555-555555555555" - tmuxLeaderSurface = "66666666-6666-4666-8666-666666666666" - tmuxSpawnSurface = "77777777-7777-4777-8777-777777777777" - tmuxLeaderPane = "88888888-8888-4888-8888-888888888888" - tmuxSpawnPane = "99999999-9999-4999-8999-999999999999" -) - -type tmuxAgentRequest struct { - method string - params map[string]any -} - -func tmuxHelperFixture(id string, parentId string, host string) map[string]any { - return map[string]any{ - "id": id, - "ref": "workspace:" + id[:1], - "title": id, - "agent_parent_workspace_id": parentId, - "helpers": []map[string]any{{ - "id": "agent-" + id, - "host": host, - "workspace_id": id, - }}, - } -} - -func tmuxTeamWorkspaceFixture() []map[string]any { - return []map[string]any{ - { - "id": tmuxLeaderWorkspace, - "ref": "workspace:1", - "index": 1, - "title": "Lead", - }, - tmuxHelperFixture(tmuxTeamWorkspaceA, tmuxLeaderWorkspace, "claude-teams"), - tmuxHelperFixture(tmuxOtherWorkspace, tmuxTeamWorkspaceA, "codex"), - tmuxHelperFixture(tmuxTeamWorkspaceB, tmuxTeamWorkspaceA, "claude-teams"), - } -} - -func startMockAgentTmuxSocket( - t *testing.T, - workspaceItems []map[string]any, -) (string, *[]tmuxAgentRequest, *sync.Mutex) { - t.Helper() - sockPath := makeShortUnixSocketPath(t) - cwd := t.TempDir() - requests := []tmuxAgentRequest{} - var lock sync.Mutex - if len(workspaceItems) > 0 { - workspaceItems[0]["active"] = true - workspaceItems[0]["current_directory"] = cwd - } - - ln, err := net.Listen("unix", sockPath) - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - t.Cleanup(func() { _ = ln.Close() }) - - go func() { - for { - conn, err := ln.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - line, err := bufio.NewReader(conn).ReadBytes('\n') - if err != nil { - return - } - var req map[string]any - if err := json.Unmarshal(line, &req); err != nil { - return - } - method, _ := req["method"].(string) - params, _ := req["params"].(map[string]any) - lock.Lock() - requests = append(requests, tmuxAgentRequest{method: method, params: params}) - lock.Unlock() - - workspaceId, _ := params["workspace_id"].(string) - paneId := tmuxLeaderPane - surfaceId := tmuxLeaderSurface - paneRef := "pane:1" - surfaceRef := "surface:1" - if workspaceId == tmuxSpawnWorkspace { - paneId = tmuxSpawnPane - surfaceId = tmuxSpawnSurface - paneRef = "pane:2" - surfaceRef = "surface:2" - } - resp := map[string]any{"id": req["id"], "ok": true} - switch method { - case "system.identify": - resp["result"] = map[string]any{ - "focused": map[string]any{ - "workspace_id": tmuxLeaderWorkspace, - "workspace_ref": "workspace:1", - "pane_id": "pane:1", - "pane_ref": "pane:1", - "surface_ref": "surface:1", - }, - } - case "workspace.list": - resp["result"] = map[string]any{"workspaces": workspaceItems} - case "surface.current": - resp["result"] = map[string]any{ - "workspace_id": workspaceId, - "pane_id": paneId, - "surface_id": surfaceId, - } - case "surface.list": - resp["result"] = map[string]any{"surfaces": []map[string]any{{ - "id": surfaceId, - "ref": surfaceRef, - "focused": true, - "selected_in_pane": true, - "pane_id": paneId, - "pane_ref": paneRef, - "title": "leader", - "requested_working_directory": cwd, - }}} - case "pane.list": - resp["result"] = map[string]any{ - "panes": []map[string]any{{ - "id": paneId, - "ref": paneRef, - "index": 1, - "focused": true, - "columns": 120, - "rows": 40, - "cell_width_px": 10, - "cell_height_px": 20, - "pixel_frame": map[string]any{"x": 0, "y": 0, "width": 1200, "height": 800}, - "surface_ids": []any{surfaceId}, - "surface_refs": []any{surfaceRef}, - "surface_count": 1, - "selected_surface_id": surfaceId, - }}, - "container_frame": map[string]any{"width": 1200, "height": 800}, - } - case "pane.surfaces": - resp["result"] = map[string]any{"surfaces": []map[string]any{{ - "id": surfaceId, - "ref": surfaceRef, - "selected": true, - "focused": true, - }}} - case "agent.spawn": - resp["result"] = map[string]any{ - "workspace_id": tmuxSpawnWorkspace, - "surface_id": tmuxSpawnSurface, - } - case "agent.task.list": - resp["result"] = map[string]any{"agents": []map[string]any{{ - "id": "agent-" + workspaceId, - }}} - case "agent.task.finish", "workspace.close", "surface.close", "workspace.equalize_splits": - resp["result"] = map[string]any{"ok": true} - default: - resp["ok"] = false - resp["error"] = map[string]any{"code": "unsupported", "message": method} - } - - payload, _ := json.Marshal(resp) - _, _ = conn.Write(append(payload, '\n')) - }(conn) - } - }() - - return sockPath, &requests, &lock -} - -func TestTmuxSplitWindowSpawnsNestedTeamHelper(t *testing.T) { - t.Setenv("PROGRAMA_WORKSPACE_ID", tmuxLeaderWorkspace) - t.Setenv("PROGRAMA_SURFACE_ID", tmuxLeaderSurface) - sockPath, requests, lock := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - - output := captureStdout(t, func() { - if err := dispatchTmuxCommand(rc, "split-window", []string{ - "-h", "-P", "-F", "#{pane_id}", "-c", "/repo", "claude", "--agent", - }); err != nil { - t.Fatalf("split-window: %v", err) - } - }) - - wantOutput := "%" + tmuxStableNumericId(tmuxSpawnPane) + "\n" - if output != wantOutput { - t.Fatalf("stdout = %q, want %q", output, wantOutput) - } - - lock.Lock() - defer lock.Unlock() - spawnCount := 0 - for _, request := range *requests { - if request.method == "surface.split" || request.method == "surface.send_text" { - t.Fatalf("split-window used obsolete surface path: %s", request.method) - } - if request.method != "agent.spawn" { - continue - } - spawnCount++ - if request.params["parent_workspace_id"] != tmuxLeaderWorkspace || - request.params["host"] != "claude-teams" || - request.params["task"] != "Helper" || request.params["focus"] != false { - t.Errorf("agent.spawn params = %v", request.params) - } - if request.params["initial_command"] != "cd -- '/repo' && claude --agent\r" { - t.Errorf("initial_command = %q", request.params["initial_command"]) - } - } - if spawnCount != 1 { - t.Fatalf("agent.spawn calls = %d, want 1", spawnCount) - } -} - -func TestTmuxListPanesOnlyTraversesClaudeTeamSubtree(t *testing.T) { - t.Setenv("PROGRAMA_WORKSPACE_ID", tmuxLeaderWorkspace) - sockPath, _, _ := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - - output := captureStdout(t, func() { - if err := dispatchTmuxCommand(rc, "list-panes", []string{"-F", "#{window_uuid}"}); err != nil { - t.Fatalf("list-panes: %v", err) - } - }) - want := tmuxLeaderWorkspace + "\n" + tmuxTeamWorkspaceA + "\n" + tmuxTeamWorkspaceB + "\n" - if output != want { - t.Fatalf("stdout = %q, want %q", output, want) - } -} - -func TestTmuxKillWindowFinishesTeamHelpersDeepestFirst(t *testing.T) { - t.Setenv("PROGRAMA_WORKSPACE_ID", tmuxLeaderWorkspace) - sockPath, requests, lock := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - - if err := dispatchTmuxCommand(rc, "kill-window", []string{"-t", tmuxLeaderWorkspace}); err != nil { - t.Fatalf("kill-window: %v", err) - } - - lock.Lock() - defer lock.Unlock() - finished := []string{} - closed := []string{} - for _, request := range *requests { - switch request.method { - case "agent.task.list": - if request.params["include_finished"] != false { - t.Errorf("agent.task.list params = %v", request.params) - } - case "agent.task.finish": - if request.params["state"] != "cancelled" { - t.Errorf("agent.task.finish params = %v", request.params) - } - finished = append(finished, stringFromAnyGo(request.params["agent_id"])) - case "workspace.close": - closed = append(closed, stringFromAnyGo(request.params["workspace_id"])) - } - } - wantFinished := []string{"agent-" + tmuxTeamWorkspaceB, "agent-" + tmuxTeamWorkspaceA} - if !equalStringSlices(finished, wantFinished) { - t.Fatalf("finished helpers = %v, want %v", finished, wantFinished) - } - wantClosed := []string{tmuxTeamWorkspaceB, tmuxTeamWorkspaceA, tmuxLeaderWorkspace} - if !equalStringSlices(closed, wantClosed) { - t.Fatalf("closed workspaces = %v, want %v", closed, wantClosed) - } -} - -func TestTmuxKillSinglePaneClosesTeamWorkspaceLifecycle(t *testing.T) { - t.Setenv("PROGRAMA_WORKSPACE_ID", tmuxTeamWorkspaceA) - sockPath, requests, lock := startMockAgentTmuxSocket(t, tmuxTeamWorkspaceFixture()) - rc := &rpcContext{socketPath: sockPath} - - if err := dispatchTmuxCommand(rc, "kill-pane", []string{"-t", tmuxTeamWorkspaceA}); err != nil { - t.Fatalf("kill-pane: %v", err) - } - - lock.Lock() - defer lock.Unlock() - closed := []string{} - for _, request := range *requests { - if request.method == "surface.close" { - t.Fatal("single-pane team helper used surface.close") - } - if request.method == "workspace.close" { - closed = append(closed, stringFromAnyGo(request.params["workspace_id"])) - } - } - want := []string{tmuxTeamWorkspaceB, tmuxTeamWorkspaceA} - if !equalStringSlices(closed, want) { - t.Fatalf("closed workspaces = %v, want %v", closed, want) - } -} - -func TestTmuxTeamWorkspaceIdsUsesStableOrderForCycles(t *testing.T) { - items := []map[string]any{ - tmuxHelperFixture(tmuxTeamWorkspaceB, tmuxTeamWorkspaceA, "claude-teams"), - tmuxHelperFixture(tmuxTeamWorkspaceA, tmuxTeamWorkspaceB, "claude-teams"), - } - got := tmuxTeamWorkspaceIds(tmuxTeamWorkspaceA, items) - want := []string{tmuxTeamWorkspaceB, tmuxTeamWorkspaceA} - if !equalStringSlices(got, want) { - t.Fatalf("team cycle order = %v, want %v", got, want) - } -} - -func equalStringSlices(got []string, want []string) bool { - if len(got) != len(want) { - return false - } - for index := range got { - if got[index] != want[index] { - return false - } - } - return true -} diff --git a/daemon/remote/cmd/programad-remote/tmux_store.go b/daemon/remote/cmd/programad-remote/tmux_store.go deleted file mode 100644 index a066c232..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_store.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" -) - -// --- TmuxCompatStore (local JSON state) --- - -type tmuxCompatStore struct { - Buffers map[string]string `json:"buffers,omitempty"` - Hooks map[string]string `json:"hooks,omitempty"` -} - -func tmuxCompatStoreURL() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".programaterm", "tmux-compat-store.json") -} - -func loadTmuxCompatStore() tmuxCompatStore { - data, err := os.ReadFile(tmuxCompatStoreURL()) - if err != nil { - return tmuxCompatStore{ - Buffers: make(map[string]string), - Hooks: make(map[string]string), - } - } - var store tmuxCompatStore - if err := json.Unmarshal(data, &store); err != nil { - return tmuxCompatStore{ - Buffers: make(map[string]string), - Hooks: make(map[string]string), - } - } - if store.Buffers == nil { - store.Buffers = make(map[string]string) - } - if store.Hooks == nil { - store.Hooks = make(map[string]string) - } - return store -} - -func saveTmuxCompatStore(store tmuxCompatStore) error { - path := tmuxCompatStoreURL() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - data, err := json.Marshal(store) - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} diff --git a/daemon/remote/cmd/programad-remote/tmux_target.go b/daemon/remote/cmd/programad-remote/tmux_target.go deleted file mode 100644 index 896f6499..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_target.go +++ /dev/null @@ -1,596 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" -) - -// --- Target resolution --- - -func tmuxCallerWorkspaceHandle() string { - return strings.TrimSpace(os.Getenv("PROGRAMA_WORKSPACE_ID")) -} - -func tmuxCallerSurfaceHandle() string { - return strings.TrimSpace(os.Getenv("PROGRAMA_SURFACE_ID")) -} - -func tmuxResolvedCallerWorkspaceId(rc *rpcContext) string { - caller := tmuxCallerWorkspaceHandle() - if caller == "" { - return "" - } - wsId, err := tmuxResolveWorkspaceId(rc, caller) - if err != nil { - return "" - } - return wsId -} - -// tmuxActiveWorkspaceId reports which workspace should be treated as the -// tmux-compat "active" window: the caller's own workspace context if one is -// set (PROGRAMA_WORKSPACE_ID), otherwise whatever the app currently has -// selected. Used so window_active/window_flags reflect real focus instead -// of always claiming every window is active. -func tmuxActiveWorkspaceId(rc *rpcContext) string { - if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs != "" { - return callerWs - } - payload, err := rc.call("workspace.current", nil) - if err != nil { - return "" - } - if wsId, _ := payload["workspace_id"].(string); wsId != "" { - return wsId - } - if wsRef, _ := payload["workspace_ref"].(string); wsRef != "" { - if wsId, err := tmuxResolveWorkspaceId(rc, wsRef); err == nil { - return wsId - } - } - return "" -} - -func tmuxCallerPaneHandle() string { - for _, key := range []string{"TMUX_PANE", "PROGRAMA_PANE_ID"} { - v := strings.TrimSpace(os.Getenv(key)) - if v != "" { - return strings.TrimPrefix(v, "%") - } - } - return "" -} - -func tmuxWorkspaceItems(rc *rpcContext) ([]map[string]any, error) { - payload, err := rc.call("workspace.list", nil) - if err != nil { - return nil, err - } - items, _ := payload["workspaces"].([]any) - var result []map[string]any - for _, item := range items { - if m, ok := item.(map[string]any); ok { - result = append(result, m) - } - } - return result, nil -} - -func isUUIDish(s string) bool { - // Simple UUID check: 8-4-4-4-12 hex - if len(s) != 36 { - return false - } - for i, c := range s { - if i == 8 || i == 13 || i == 18 || i == 23 { - if c != '-' { - return false - } - } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return false - } - } - return true -} - -// tmuxTrimIdSigil strips any leading run of tmux id sigils ($, @, %) from a -// selector, e.g. "$123" -> "123", "%%abc" -> "abc". -func tmuxTrimIdSigil(raw string) string { - raw = strings.TrimSpace(raw) - for raw != "" { - switch raw[0] { - case '$', '@', '%': - raw = strings.TrimSpace(raw[1:]) - default: - return raw - } - } - return raw -} - -// tmuxSelectorToken returns the sigil-stripped token for a selector along -// with whether a sigil was actually present. Sigiled selectors (the ones a -// real tmux client sends back after reading a format field like -// #{pane_id}) must resolve by exact numeric-id or UUID match only -- they -// must never fall through to ref/index matching, which is reserved for -// plain user-typed selectors. -func tmuxSelectorToken(raw string) (string, bool) { - trimmed := strings.TrimSpace(raw) - token := tmuxTrimIdSigil(trimmed) - return token, token != trimmed -} - -// tmuxNumericIdMatches reports whether handle (sigil optional) equals the -// stable numeric id of any of the given candidate UUIDs/refs. -func tmuxNumericIdMatches(handle string, candidates ...string) bool { - token := tmuxTrimIdSigil(handle) - if token == "" { - return false - } - for _, candidate := range candidates { - if strings.TrimSpace(candidate) == "" { - continue - } - if token == tmuxStableNumericId(candidate) { - return true - } - } - return false -} - -// tmuxIndexMatches reports whether handle (sigil optional) is the decimal -// string form of index. -func tmuxIndexMatches(handle string, index int) bool { - if index < 0 { - return false - } - return tmuxTrimIdSigil(handle) == fmt.Sprintf("%d", index) -} - -func tmuxResolveWorkspaceId(rc *rpcContext, raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" || raw == "current" { - if caller := tmuxCallerWorkspaceHandle(); caller != "" { - if isUUIDish(caller) { - return caller, nil - } - // Resolve ref - return tmuxResolveWorkspaceId(rc, caller) - } - payload, err := rc.call("workspace.current", nil) - if err != nil { - return "", fmt.Errorf("no workspace selected: %w", err) - } - if wsId, ok := payload["workspace_id"].(string); ok { - return wsId, nil - } - return "", fmt.Errorf("no workspace selected") - } - - if isUUIDish(raw) { - return raw, nil - } - - token, sigiled := tmuxSelectorToken(raw) - if isUUIDish(token) { - return token, nil - } - - // Try to resolve as ref, tmux numeric id, or workspace index. - items, err := tmuxWorkspaceItems(rc) - if err != nil { - return "", err - } - for _, item := range items { - id, _ := item["id"].(string) - if ref, _ := item["ref"].(string); !sigiled && ref == raw { - if id != "" { - return id, nil - } - } - if id == raw || id == token { - return id, nil - } - if tmuxNumericIdMatches(token, id) || tmuxNumericIdMatches(token, stringFromAnyGo(item["ref"])) { - if id != "" { - return id, nil - } - } - if !sigiled && tmuxIndexMatches(token, intFromAnyGo(item["index"])) && id != "" { - return id, nil - } - } - - // Try name match - if !sigiled { - needle := strings.TrimSpace(token) - for _, item := range items { - title, _ := item["title"].(string) - if strings.TrimSpace(title) == needle { - if id, _ := item["id"].(string); id != "" { - return id, nil - } - } - } - } - - return "", fmt.Errorf("workspace not found: %s", raw) -} - -func tmuxResolveWorkspaceTarget(rc *rpcContext, raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - if caller := tmuxCallerWorkspaceHandle(); caller != "" { - return tmuxResolveWorkspaceId(rc, caller) - } - return tmuxResolveWorkspaceId(rc, "") - } - - if raw == "!" || raw == "^" || raw == "-" { - payload, err := rc.call("workspace.last", nil) - if err != nil { - return "", fmt.Errorf("previous workspace not found: %w", err) - } - if wsId, ok := payload["workspace_id"].(string); ok { - return wsId, nil - } - return "", fmt.Errorf("previous workspace not found") - } - - // Strip session:window.pane format - token := raw - if dot := strings.LastIndex(token, "."); dot >= 0 { - token = token[:dot] - } - if colon := strings.LastIndex(token, ":"); colon >= 0 { - suffix := token[colon+1:] - if suffix != "" { - token = suffix - } else { - token = token[:colon] - } - } - - return tmuxResolveWorkspaceId(rc, token) -} - -func tmuxPaneSelector(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "%") { - return raw - } - if strings.HasPrefix(raw, "pane:") { - return raw - } - if dot := strings.LastIndex(raw, "."); dot >= 0 { - return raw[dot+1:] - } - return "" -} - -func tmuxWindowSelector(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "%") || strings.HasPrefix(raw, "pane:") { - return "" - } - if dot := strings.LastIndex(raw, "."); dot >= 0 { - return raw[:dot] - } - return raw -} - -func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (string, error) { - handle, sigiled := tmuxSelectorToken(handle) - if isUUIDish(handle) { - return handle, nil - } - payload, err := rc.call("pane.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - panes, _ := payload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - id, _ := pane["id"].(string) - ref, _ := pane["ref"].(string) - if !sigiled && ref == handle { - if id != "" { - return id, nil - } - } - if id == handle { - return id, nil - } - if tmuxNumericIdMatches(handle, id) || tmuxNumericIdMatches(handle, ref) { - if id != "" { - return id, nil - } - } - } - if !sigiled { - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - id, _ := pane["id"].(string) - if tmuxIndexMatches(handle, intFromAnyGo(pane["index"])) && id != "" { - return id, nil - } - } - } - return "", fmt.Errorf("pane not found: %s", handle) -} - -func tmuxCanonicalSurfaceId(rc *rpcContext, handle string, workspaceId string) (string, error) { - handle, sigiled := tmuxSelectorToken(handle) - payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - id, _ := surface["id"].(string) - ref, _ := surface["ref"].(string) - if !sigiled && ref == handle { - if id != "" { - return id, nil - } - } - if id == handle { - return id, nil - } - if tmuxNumericIdMatches(handle, id) || tmuxNumericIdMatches(handle, ref) { - if id != "" { - return id, nil - } - } - } - if !sigiled { - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - id, _ := surface["id"].(string) - if tmuxIndexMatches(handle, intFromAnyGo(surface["index"])) && id != "" { - return id, nil - } - } - } - return "", fmt.Errorf("surface not found: %s", handle) -} - -func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) { - payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - if pid, ok := payload["pane_id"].(string); ok { - return pid, nil - } - if pref, ok := payload["pane_ref"].(string); ok { - return tmuxCanonicalPaneId(rc, pref, workspaceId) - } - return "", fmt.Errorf("pane not found") -} - -func tmuxWorkspaceIdForPaneHandle(rc *rpcContext, handle string) (string, error) { - handle, sigiled := tmuxSelectorToken(handle) - workspaces, err := tmuxWorkspaceItems(rc) - if err != nil { - return "", err - } - for _, ws := range workspaces { - wsId, _ := ws["id"].(string) - if wsId == "" { - continue - } - payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - continue - } - panes, _ := payload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - pid, _ := pane["id"].(string) - pref, _ := pane["ref"].(string) - if pid == handle { - return wsId, nil - } - if !sigiled && pref == handle { - return wsId, nil - } - if tmuxNumericIdMatches(handle, pid) || tmuxNumericIdMatches(handle, pref) { - return wsId, nil - } - if !sigiled && tmuxIndexMatches(handle, intFromAnyGo(pane["index"])) { - return wsId, nil - } - } - } - return "", fmt.Errorf("pane not found in any workspace") -} - -func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, err error) { - raw = strings.TrimSpace(raw) - paneSelector := tmuxPaneSelector(raw) - windowSelector := tmuxWindowSelector(raw) - - if windowSelector != "" { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, windowSelector) - if err != nil { - return "", "", err - } - } else if paneSelector != "" { - // Prefer the caller's own workspace context when the selector - // resolves within it, so canonicalization keeps a stale/global - // pane.list scan from picking a different workspace that happens - // to contain a same-named pane. - if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs != "" { - if _, err2 := tmuxCanonicalPaneId(rc, paneSelector, callerWs); err2 == nil { - workspaceId = callerWs - } - } - if workspaceId == "" { - workspaceId, err = tmuxWorkspaceIdForPaneHandle(rc, paneSelector) - } - if err != nil { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return "", "", err - } - } - } else { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return "", "", err - } - } - - if paneSelector != "" { - paneId, err = tmuxCanonicalPaneId(rc, paneSelector, workspaceId) - if err != nil { - return "", "", err - } - } else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { - if callerPane := tmuxCallerPaneHandle(); callerPane != "" { - if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil { - paneId = pid - } - } - } - - if paneId == "" { - paneId, err = tmuxFocusedPaneId(rc, workspaceId) - if err != nil { - return "", "", err - } - } - return workspaceId, paneId, nil -} - -func tmuxSelectedSurfaceId(rc *rpcContext, workspaceId string, paneId string) (string, error) { - payload, err := rc.call("pane.surfaces", map[string]any{"workspace_id": workspaceId, "pane_id": paneId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if sel, _ := boolFromAnyGo(surface["selected"]); sel { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - } - // Fall back to first surface - if len(surfaces) > 0 { - if surface, ok := surfaces[0].(map[string]any); ok { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - } - return "", fmt.Errorf("pane has no surface") -} - -func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, surfaceId string, err error) { - raw = strings.TrimSpace(raw) - - if tmuxPaneSelector(raw) != "" { - workspaceId, paneId, err = tmuxResolvePaneTarget(rc, raw) - if err != nil { - return "", "", "", err - } - // When target pane matches caller's pane, prefer caller's surface - callerPane := tmuxCallerPaneHandle() - callerSurface := tmuxCallerSurfaceHandle() - if callerPane != "" && callerSurface != "" { - canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) - if paneId == callerPane || paneId == canonicalCallerPane { - surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) - if err == nil { - return - } - } - } - surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) - return - } - - winSel := tmuxWindowSelector(raw) - workspaceId, err = tmuxResolveWorkspaceTarget(rc, winSel) - if err != nil { - return "", "", "", err - } - - // When no explicit target and caller workspace matches, use caller's surface - if winSel == "" { - if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { - if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { - surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) - if err == nil { - return - } - } - } - } - - // Fall back to focused surface - payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) - if err == nil { - if sid, ok := payload["surface_id"].(string); ok { - surfaceId = sid - return - } - } - - // Last resort: first surface in the workspace - surfPayload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err == nil { - surfs, _ := surfPayload["surfaces"].([]any) - for _, s := range surfs { - surf, _ := s.(map[string]any) - if surf == nil { - continue - } - if focused, _ := boolFromAnyGo(surf["focused"]); focused { - if id, _ := surf["id"].(string); id != "" { - surfaceId = id - return workspaceId, "", surfaceId, nil - } - } - } - if len(surfs) > 0 { - if surf, ok := surfs[0].(map[string]any); ok { - if id, _ := surf["id"].(string); id != "" { - surfaceId = id - return workspaceId, "", surfaceId, nil - } - } - } - } - - return "", "", "", fmt.Errorf("unable to resolve surface") -} diff --git a/daemon/remote/cmd/programad-remote/tmux_waitfor.go b/daemon/remote/cmd/programad-remote/tmux_waitfor.go deleted file mode 100644 index 19f142bb..00000000 --- a/daemon/remote/cmd/programad-remote/tmux_waitfor.go +++ /dev/null @@ -1,118 +0,0 @@ -package main - -import ( - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "strings" - "syscall" -) - -// --- Wait-for (filesystem-based signaling) --- - -func tmuxWaitForSignalPath(name, socketIdentity string) (string, error) { - runtimeDir, err := tmuxWaitForRuntimeDirectory() - if err != nil { - return "", err - } - var sanitized strings.Builder - for _, c := range name { - if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '.' || c == '_' || c == '-' { - sanitized.WriteRune(c) - } else { - sanitized.WriteByte('_') - } - } - nameHash := sha256.Sum256([]byte(name)) - sessionHash := sha256.Sum256([]byte(socketIdentity)) - return filepath.Join( - runtimeDir, - fmt.Sprintf("%x-%s-%x.sig", sessionHash[:8], sanitized.String(), nameHash[:8]), - ), nil -} - -func tmuxWaitForRuntimeDirectory() (string, error) { - if xdgRuntime := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); xdgRuntime != "" { - if err := validateOwnedPrivateDirectory(xdgRuntime); err == nil { - programaDir := filepath.Join(xdgRuntime, "programa") - waitDir := filepath.Join(programaDir, "wait-for") - for _, component := range []string{programaDir, waitDir} { - if err := ensureOwnedPrivateDirectory(component); err != nil { - return "", err - } - } - return waitDir, nil - } - } - - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - programaDir := filepath.Join(home, ".programa") - runDir := filepath.Join(programaDir, "run") - waitDir := filepath.Join(runDir, "wait-for") - for _, component := range []string{programaDir, runDir, waitDir} { - if err := ensureOwnedPrivateDirectory(component); err != nil { - return "", err - } - } - return waitDir, nil -} - -func validateOwnedPrivateDirectory(path string) error { - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() || info.Mode().Perm() != 0700 { - return fmt.Errorf("runtime directory is not a private real directory") - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != uint32(os.Geteuid()) { - return fmt.Errorf("runtime directory is not owned by the current user") - } - return nil -} - -func validateOwnedPrivateSignal(path string) error { - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { - return fmt.Errorf("wait-for signal is not a private real file") - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || stat.Uid != uint32(os.Geteuid()) { - return fmt.Errorf("wait-for signal is not owned by the current user") - } - return nil -} - -func writeTmuxWaitForSignal(path string) error { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0600) - if os.IsExist(err) { - return validateOwnedPrivateSignal(path) - } - if err != nil { - return fmt.Errorf("create wait-for signal: %w", err) - } - if _, err := file.WriteString("signal\n"); err != nil { - file.Close() - return fmt.Errorf("write wait-for signal: %w", err) - } - if err := file.Sync(); err != nil { - file.Close() - return fmt.Errorf("sync wait-for signal: %w", err) - } - if err := file.Close(); err != nil { - return fmt.Errorf("close wait-for signal: %w", err) - } - if err := validateOwnedPrivateSignal(path); err != nil { - return fmt.Errorf("verify wait-for signal: %w", err) - } - return nil -} diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod deleted file mode 100644 index 252e60c7..00000000 --- a/daemon/remote/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/darkroomengineering/programa/daemon/remote - -go 1.26.0 - -toolchain go1.26.7 diff --git a/docs/agent-skill.md b/docs/agent-skill.md index f34baa19..940b7fcb 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -42,4 +42,4 @@ There's no automated test for "does an agent actually behave correctly" — this ## Command reference -The skill only covers the commands relevant to agent coordination. For the full CLI surface (SSH workspaces, the in-app browser, tmux-compat commands, hooks) run `programa help`, or see [`docs/v2-api-migration.md`](v2-api-migration.md) for the underlying socket API. +The skill only covers the commands relevant to agent coordination. For the full CLI surface (the in-app browser, tmux-compat commands, hooks) run `programa help`, or see [`docs/v2-api-migration.md`](v2-api-migration.md) for the underlying socket API. diff --git a/docs/audits/codebase-audit-2026-08-03.md b/docs/audits/codebase-audit-2026-08-03.md deleted file mode 100644 index 5c3192a5..00000000 --- a/docs/audits/codebase-audit-2026-08-03.md +++ /dev/null @@ -1,195 +0,0 @@ -# Codebase adversarial audit — 2026-08-03 - -Scope: runtime cost (startup, RAM, idle CPU, keystroke hot paths) plus the expectation-gap hunt — -where the code does not do what its own comments, schema, settings, or docs promise. - -Companion to the same-day nuclear-review (runtime-cost findings N1–N11). This audit hunts honesty, -not maintainability. Findings here do not repeat that report. - -## Summary - -| ID | Sev | Area | Issue | Location | Status | -|---|---|---|---|---|---| -| H1 | High | Process lifecycle | Escrow holder process has no exit path; leaks a full app-binary copy per abnormal exit | `Sources/SessionEscrow.swift:872-902` | CONFIRMED | -| H2 | High | Settings | Three documented `app.*` settings have zero readers anywhere | `Resources/settings.schema.json:44-73` | CONFIRMED | -| H3 | High | Settings | Two `browser.*` schema keys never match the parser's key names | `settings.schema.json:291-300` vs `ProgramaSettingsFileStore.swift:617-622` | CONFIRMED | -| M1 | Med | Settings | Port base/range cached in `static let`; mid-session changes ignored despite UI implying otherwise | `Sources/TerminalSurface.swift:184-191` | CONFIRMED | -| M2 | Med | Naming/boundary | A type named `…DebugStore` is a load-bearing dependency of non-debug Settings styling | `Sources/SettingsView.swift:2170` | CONFIRMED | -| M3 | Med | Socket policy | Five telemetry *read* commands block on `DispatchQueue.main.sync` | `Sources/TerminalController+Telemetry.swift:653,739,802,1013,1034` | CONFIRMED | -| M4 | Med | Doc drift | CLAUDE.md documents `cd programad && zig build`; no such directory, daemon is Go | `CLAUDE.md:71-74` | CONFIRMED | -| M5 | Med | Doc drift | CLAUDE.md focus allowlist omits six methods the code actually allows | `Sources/TerminalController.swift:120-138` | CONFIRMED | -| L1 | Low | Localization | Bare user-facing string literals | `TabItemView.swift:422,636` | CONFIRMED | - -## Remediation status (updated same day) - -- **H1** — shipped in PR #237: reaper + TTL (1h) + idle exit, plus fd-reuse and exit/accept race - fixes. Claim/renew reconciliation was built, then **dropped before shipping**: cross-model review - confirmed a 120s time-based reconcile destroys sessions that `programa snapshot restore` can - legitimately reattach at any later time (`v2SnapshotRestore` → `createMainWindow` → - `attemptSessionReattach`). Any automatic early drop conflicts with manual snapshot recovery — - the durable design needs explicit claims, not clocks. TTL remains the leak bound; tracked as an - issue. -- **N6 addendum** — `STRIP_INSTALLED_PRODUCT` alone is inert under CI's plain `xcodebuild build`; - `DEPLOYMENT_POSTPROCESSING = YES` was required to make it fire. Verified locally: 110,546 local - symbols → 0, binary 57 MB → 33 MB. -- **M4** — shipped in PR #237 (CLAUDE.md daemon section rewritten). -- **H2, H3** — in progress on `perf/audit-backlog`. -- **M2** — in progress on `perf/audit-backlog` (store relocation + `#if DEBUG` gating). -- **M1, M3, M5, L1** — open, not yet scheduled. -- Correction to the companion nuclear-review discussion: the "CVDisplayLink = 69% of activity" - figure quoted in chat was a misread of `sample(1)` output (thread presence, not CPU). The - occlusion finding stands on the unwired-`ghostty_surface_set_occlusion` evidence; measured - idle-visible CPU is ~6%. - -## Findings - -### H1 — The escrow holder never exits - -`SessionEscrowHolder.run(socketPath:) -> Never` ends in an unconditional server loop: - -```swift -while true { - let clientFD = accept(listenFD, nil, nil) - guard clientFD >= 0 else { continue } - Thread.detachNewThread { serve(connectionFD: clientFD) } -} -``` - -The file's **only two `exit()` calls (lines 881, 887) are both startup guards** — redundant-holder -detection and bind failure. Once the loop is entered there is no exit condition: not "registry -empty", not "no connections", not an idle timeout. `AppDelegate.applicationWillTerminate` -(`Sources/AppDelegate.swift:1520-1535`) tears down the autosave timer, TerminalController, -MobileBridgeListener, VSCodeServeWebController and BrowserProfileStore — and never touches escrow. - -A holder is spawned for **every** terminal surface, not only on update relaunch: -`attemptSessionEscrow` runs unconditionally from `resolveSessionWALIdentity` -(`Sources/TerminalSurface.swift:1291,1364-1381`), gated only by `SessionMachineryGate.isUnitTesting`. -Each holder is a `posix_spawn` of a second full copy of the app binary (AppKit + SwiftUI + -GhosttyKit linked in) with `POSIX_SPAWN_SETSID`. - -**Live evidence (this machine, 2026-08-03):** three orphan holders with `ppid=1`, oldest running -since Jul 31 (3d 01h), plus 12 orphaned `login`/zsh shells whose uptimes pair off exactly against -them. ~42 MB resident and ~26 pty masters held by processes whose app exited days ago. One of the -three was created by a throwaway tagged test build and survived deletion of the app, its -DerivedData, and its socket file — killing the app does not reap the holder. - -Scope note: CPU is 0.0% on all of them. This is an fd/pty/memory leak, not a CPU burn. -`kern.tty.ptmx_max` is 511 with 52 allocated, so exhaustion is not imminent — but the leak is -monotonic and never drains. - -Direction: give the holder an exit condition (registry empty + no live connections for N seconds), -and reap it explicitly from `applicationWillTerminate`. - -### H2 — Three settings documented but never read - -`Resources/settings.schema.json` documents `app.keepWorkspaceOpenWhenClosingLastSurface` (44-48), -`app.focusPaneOnFirstClick` (49-53), `app.renameSelectsExistingName` (69-73), each with a -behavioural description. Grep across `Sources/` finds zero matches for these names or any -synonym. `ProgramaSettingsFileStore.parseAppSection` (317-354) handles only `appearance`, -`newWorkspacePlacement`, `minimalMode`, `preferredEditor`, `reorderOnNotification`, -`warnBeforeQuit`, `commandPaletteSearchesAllSurfaces`. - -Scenario: a user adds `"focusPaneOnFirstClick": false` to stop first-click focus steal. The key -validates against the schema, no warning is logged, and nothing changes — because no code reads it. - -### H3 — Browser settings: schema and parser disagree on the key name - -Schema documents `browser.openTerminalLinksInCmuxBrowser` and -`browser.interceptTerminalOpenCommandInCmuxBrowser` (pre-rebrand "Cmux" naming). The parser -(`ProgramaSettingsFileStore.swift:617-622`) reads only -`openTerminalLinksInProgramaBrowser` / `interceptTerminalOpenCommandInProgramaBrowser`. - -Scenario: a user copies the key name from the schema, sets it to `false`, and links keep opening -in the embedded browser. The key is never inspected, so not even an invalid-key log fires. - -### M1 — Port base/range snapshot once per process - -```swift -static let sessionPortBase: Int = { … UserDefaults … }() // TerminalSurface.swift:184-191 -``` - -`static let` evaluates on first access — the first terminal surface created in the process — and -caches for the process lifetime. The Settings UI (`SettingsView.swift:1146`) says "New terminals -inherit these values", implying a live effect. Changing Port Base mid-session has no effect on -subsequent terminals until restart. - -### M2 — A "Debug"-named type that non-debug code depends on - -`SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to:for:)` is called from -`applyCurrentSettingsWindowStyle` in `Sources/SettingsView.swift:2170` — ordinary Settings window -styling, not debug UI. Five controllers in the same file are likewise referenced unguarded from -`Sources/ProgramaApp.swift:1122-1126,1165,1178,1265,1281`. - -This was proven the hard way: wrapping `Sources/DebugWindows.swift` in `#if DEBUG` (the obvious -reading of "this file is debug-only") produced **15 Release compile errors**. The name promises -the file is debug-scoped; the dependency graph says otherwise. The nuclear-review finding that -this file is dead weight in Release remains true, but the fix requires splitting the genuinely -non-debug store out first, not a blanket wrap. - -### M3 — Telemetry read commands block the main thread - -CLAUDE.md: "If adding a new socket command, default to off-main handling." The write side is -compliant (`v2ScheduleTelemetryMutation`). Five read-side counterparts call `v2MainSync` -(`TerminalController.swift:2058-2068`, a real `DispatchQueue.main.sync`) from per-connection -detached threads: - -`v2WorkspaceListStatus:653`, `v2WorkspaceListLog:739`, `v2WorkspaceSidebarState:802`, -`v2WorkspaceListMetaBlocks:1013`, `v2WorkspaceResetSidebar:1034`. - -A sixth (`v2WorkspaceClearMetaBlock:993`) carries an explicit comment justifying it as rare; the -other five do not. No high-frequency caller exists today, so impact is PLAUSIBLE rather than -proven — but any external tool polling `sidebar_state` for a dashboard would stall the main thread -in exactly the way this policy was written to prevent. - -### M4 / M5 — Documentation drift - -- CLAUDE.md:71-74 instructs `cd programad && zig build -Doptimize=ReleaseFast`. There is no - `programad/` directory; the remote daemon is `daemon/remote/cmd/programad-remote`, and it is - **Go** (19 `.go` files, 0 `.zig` files). The documented build command cannot succeed. -- `focusIntentV2Methods` (`TerminalController.swift:120-138`) additionally allows `review.open`, - `worktree.create`, `worktree.open`, `debug.command_palette.toggle`, `debug.notification.focus`, - `debug.app.activate` — none in CLAUDE.md's written allowlist. The policy grew; the doc did not. - -### L1 — Bare user-facing strings - -`Sources/TabItemView.swift:422` (`Text("\(unreadCount)")`) and `:636` -(`Text("\(pullRequest.label) #\(pullRequest.number)")`). Small scale; a spot-check, not exhaustive. - -## Upheld contracts - -- **Socket focus policy** — a real allowlist checked once at dispatch entry, with non-allowlisted - commands gated through `v2FocusAllowed()` so a caller-supplied `focus` param cannot override. -- **No app-level display link / manual draw loop** — zero `ghostty_surface_draw` calls; the only - `CVDisplayLink`s are ghostty's per-surface vsync and Bonsplit's divider-drag animator. -- **Terminal find layering** — `SurfaceSearchOverlay` is mounted from `GhosttySurfaceScrollView`, - with an explicit warning comment against the SwiftUI path in `TerminalPanelView.swift:50`. -- **Feature toggles genuinely stop work** — `MobileBridge` off stops both listener and push; - `AgentScreenDetection` off short-circuits its tick to a plain sleep. - -## Considered and rejected - -- **Bonsplit `SplitAnimator` leak** — every path that empties `animations` calls `stopIfNeeded()` - in the same turn, including the weak-ref sweep. No leak. -- **PortScanner `agentScanTimer` leak** — a closed workspace is dropped on the next 2s tick via - `agentPIDsProvider`; worst case a ~2s tail, not permanent. -- **SessionWALStore drain/frame timers** — all five `unregister()` call sites run on the same - serial queue as `startWriter`, and `stopWriter()` always calls `stopTimersIfIdle()`. -- **"Zig daemon" idle cost** — N/A. No resident Zig daemon exists; the Go remote daemon is spawned - per SSH connection and exits on stdin EOF. -- **`GhosttyConfig.scrollbackLimit` "parsed but never wired"** — false. It mirrors a ghostty - config-file key that ghostty loads itself; the Programa-side reader is a line-count estimate by - design. Recorded so this is not re-litigated. - -## Open questions - -1. Should the escrow holder self-terminate on an idle timeout, or be reaped explicitly at app quit? - The current design intentionally outlives the app — the exit condition is a product decision. -2. Are the three phantom `app.*` settings unimplemented features or removed ones? Delete from the - schema, or implement? -3. `browser.*` key mismatch — accept both names for back-compat, or fix the schema only? - -## Not covered - -Shortcut-policy conformance (whether every branch of `handleCustomShortcut` consults -`KeyboardShortcutSettings`), and the sweep for performance-claiming comments that the code no -longer honors. Both ran out of budget; neither was started rather than partially done. diff --git a/docs/audits/codebase-audit-2026-08-31.md b/docs/audits/codebase-audit-2026-08-31.md deleted file mode 100644 index d4549b49..00000000 --- a/docs/audits/codebase-audit-2026-08-31.md +++ /dev/null @@ -1,395 +0,0 @@ -# Codebase Audit — 2026-08-31 - -**Verdict: REMEDIATED WITH ONE UPSTREAM BLOCKER.** The landing series resolves all 10 high-severity findings, 17 of 18 medium-severity findings, and the low-severity finding. M12 is complete for Sparkle; the Iroh update remains blocked by an invalid checksum in the latest stable upstream release rather than by local code. - -Audit basis: `main` at `2097c7b795` on 2026-08-31. This was a read-only source audit. Vendored/generated code, the Ghostty submodule's internals, and dependency build products were excluded. No local tests or builds were run because the repository policy sends tests to CI/VM. Standalone Codex has no reliable Swift dead-code scanner here, so this report makes no claim that the repository is free of dead code. Team knowledge was unavailable because `KNOWLEDGE_REPO_PATH` was not reachable. - -Remediation basis: the full uncommitted landing candidate built successfully with `./scripts/reload.sh --tag audit-fixes` on 2026-08-31. Local suites were not run, per repository policy; the regression suite is assigned to GitHub Actions in a red/green commit sequence. Structural CI budgets keep the five audited entry-point families below their pre-remediation combined line counts. The Iroh `v1.0.2-cmux.8` manifest declares SHA-256 `f9218939c1e8a74d1db77ea57fefc41a772e3f1e854925cb35b693495df6be9a`, while its published release asset reports `77bced2458c672e1c8d903561a6866a6ef7e29ec807068520cefd2832041bb29`. The only newer release, `v1.0.2-cmux.9-dev.1`, is explicitly prerelease, so the known-good `.3` pin remains until upstream publishes a corrected stable artifact. - -## Summary - -| ID | Severity | Area | Issue | Location | Status | -|---|---|---|---|---|---| -| H1 | HIGH | Structure | 48 production files exceed 1,000 lines; five entry points own several unrelated systems | `Sources/AppDelegate.swift:1` | RESOLVED | -| H2 | HIGH | CLI / protocols | Four hand-maintained clients disagree about protocol version, relay auth, and password auth | `daemon/remote/cmd/programad-remote/cli.go:50` | RESOLVED | -| H3 | HIGH | Remote execution | A predictable shared `/tmp` module is injected into every remote Claude Node process | `daemon/remote/cmd/programad-remote/agent_launch.go:393` | RESOLVED | -| H4 | HIGH | Remote bootstrap | Downloaded daemon files are used after URLSession invalidates their temporary URL; timeout state also races | `Sources/WorkspaceRemoteSessionController+DaemonInstall.swift:300` | RESOLVED | -| H5 | HIGH | Settings / startup | Positive but out-of-range port settings can overflow and trap when the first terminal starts | `Sources/TerminalSurface.swift:1300` | RESOLVED | -| H6 | HIGH | Settings / concurrency | Managed-settings callbacks can concurrently mutate unsynchronized directory-trust state | `Sources/ProgramaSettingsFileStore.swift:118` | RESOLVED | -| H7 | HIGH | Session restore | Snapshot bytes and recursive layout trees are fully decoded before reconstruction caps apply | `Sources/SessionPersistence.swift:388` | RESOLVED | -| H8 | HIGH | iOS RPC | The timeout helper cannot cancel a request parked in a checked continuation | `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:281` | RESOLVED | -| H9 | HIGH | CI supply chain | Mutable action tags execute with an OAuth secret and OIDC permission | `.github/workflows/claude.yml:21` | RESOLVED | -| H10 | HIGH | Go dependency | The remote daemon is built with the unsupported Go 1.22 language/toolchain contract | `daemon/remote/go.mod:3` | RESOLVED | -| M1 | MEDIUM | iOS framing | The iOS reader has no frame cap and rescans its growing buffer from byte zero | `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:388` | RESOLVED | -| M2 | MEDIUM | iOS identity | The long-lived Iroh identity and pairing ticket are stored in UserDefaults | `ios/ProgramaSpike/ProgramaSpike/SecretKeyStore.swift:3` | RESOLVED | -| M3 | MEDIUM | Browser security | Installed extensions receive every requested permission and host match forever, without consent | `Sources/Panels/BrowserExtensionManager.swift:111` | RESOLVED | -| M4 | MEDIUM | Input bounds | Browser downloads, history, VS Code output, and review diffs read untrusted/unbounded data before caps | `Sources/Panels/ProgramaWebView.swift:1158` | RESOLVED | -| M5 | MEDIUM | Session escrow | Holder election probes and binds separately, so a second holder can unlink the first holder's live socket | `Sources/SessionEscrow.swift:1288` | RESOLVED | -| M6 | MEDIUM | Process execution | Three near-identical subprocess runners duplicate timeout, pipe, kill, and decoding policy | `Sources/GitMetadataProber.swift:495` | RESOLVED | -| M7 | MEDIUM | iOS CloudKit | A local boolean permanently suppresses subscription repair after account changes or server deletion | `ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift:19` | RESOLVED | -| M8 | MEDIUM | iOS diagnostics | Production returns the first relay path although the validated spike waits for path settlement | `ios/ProgramaSpike/ProgramaSpike/PathClassifier.swift:42` | RESOLVED | -| M9 | MEDIUM | Setup | Setup accepts any Zig although Ghostty requires exactly the 0.16 line | `scripts/setup.sh:12` | RESOLVED | -| M10 | MEDIUM | Release tooling | The TestFlight script replaces the user's Keychain search list and never restores it | `scripts/build-ios-testflight.sh:55` | RESOLVED | -| M11 | MEDIUM | iOS structure | CmuxIrohTransport is linked but unused while a completed spike remains a divergent second implementation | `ios/ProgramaSpike/project.yml:5` | RESOLVED | -| M12 | MEDIUM | Dependencies | Sparkle and the Iroh fork miss released security/reliability fixes | `GhosttyTabs.xcodeproj/project.pbxproj:2236` | PARTIAL — IROH UPSTREAM BLOCKED | -| M13 | MEDIUM | Build scripts | App discovery is copied across scripts with conflicting stale-build selection | `scripts/reload.sh:324` | RESOLVED | -| M14 | MEDIUM | CLI | `programa race` waits for Git before draining stdout and can deadlock on a large ref list | `CLI/programa.swift:5877` | RESOLVED | -| M15 | MEDIUM | tmux compatibility | `wait-for` uses a shared predictable `/tmp` path and ignores write/remove failures | `daemon/remote/cmd/programad-remote/tmux_waitfor.go:10` | RESOLVED | -| M16 | MEDIUM | Remote process | Old relay stderr callbacks can append after teardown and contaminate the next relay's diagnostic buffer | `Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift:262` | RESOLVED | -| M17 | MEDIUM | Notifications | User-notification delegate callbacks touch AppKit/model state without an explicit main-actor hop | `Sources/AppDelegate.swift:10301` | RESOLVED | -| M18 | MEDIUM | iOS reconnect | A failed retry can clear the only reconnect task after the phase consumer declines to schedule another | `ios/ProgramaSpike/ProgramaSpike/AppStore.swift:276` | RESOLVED | -| L1 | LOW | iOS docs / UX | Pairing recovery tells users to use controls that were removed | `ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift:108` | RESOLVED | - -## System map - -```text -macOS app - AppDelegate / ProgramaApp - -> windows + workspaces + panels - -> TerminalController JSON-RPC socket - -> session snapshots / WAL / escrow - -> browser, extensions, downloads, history - -> remote SSH session controller - -clients of the socket contract - CLI/programa.swift -------- local Swift client - CLI-MCP/* ----------------- MCP adapter - programad-remote cli.go --- remote Go client - MobileBridge -------------- Iroh relay for iOS - -delivery - setup/reload scripts -> GhosttyKit -> Xcode - CI -> release workflow -> signed/notarized rolling release - iOS TestFlight workflow -> ProgramaSpike + widgets -``` - -The principal ownership problem is that the socket contract, subprocess lifecycle, bounded-input policy, and remote-auth framing have no single source of truth. Each consumer reimplements enough of the contract to drift independently. - -### Expectation gaps - -- Expected every advertised remote CLI command to use the only supported socket protocol; found six v1 commands whose server always returns `v1_removed`, while the CLI exits zero. -- Expected `programa-mcp` to work whenever Programa's documented socket modes work; found no `auth.login` flow for password mode. -- Expected the Swift and Go relay clients to authenticate to the same server; found `cmux-relay-auth` in Swift and `programa-relay-auth` in the server and Go client. -- Expected positive port settings to be valid ports; found no upper-bound or overflow validation. -- Expected “New terminals inherit these values” to mean settings apply to new terminals; found process-lifetime `static let` caching. -- Expected a 15-second mobile request timeout to return in 15 seconds; found a task-group child that cannot be cancelled until teardown, while teardown waits for the helper. -- Expected the shipped iOS target to have replaced its “spike-grade” key storage; found TestFlight automation around a UserDefaults identity. -- Expected setup to reject an incompatible Zig before an expensive Ghostty build; found presence-only checks. - -## Code-Judo opportunities - -### J1 — Make the JSON-RPC contract the product boundary, not copied client code - -Delete `protoV1`, the six v1 command specs, and the v1 executor. Define methods, parameter names, auth handshake identifiers, and error codes once, then generate or share thin Swift/Go/MCP client bindings. This removes H2's three independent failures and makes remote/local behavior mechanically comparable. - -### J2 — One bounded process runner, one bounded byte-reader policy - -Extract the already-correct process/pipe lifecycle into one owner used by Git metadata, review diffs, worktrees, and `race`. Separately, make byte limits part of the reader API rather than caller convention. Port the Mac mobile reader to iOS instead of maintaining an inlined cousin. This deletes M1, M4, M6, and M14's recurring failure shapes rather than patching each call site. - -### J3 — Treat iOS as a production client or remove it from the shipping graph - -The repository simultaneously calls the target a spike, auto-ships it to TestFlight, links an unused transport package, and keeps a second executable spike as reference code. Promote one implementation: Keychain identity, cancellable requests, bounded framing, settled path classification, subscription reconciliation, and tests. Then remove the compiled Cmux dependency and completed executable spike. If it is genuinely experimental, remove auto-shipping instead. - -### J4 — Split entry-point coordinators by owned lifecycle - -Do not continue adding extensions to `AppDelegate`, `CLI/programa.swift`, `ContentView`, `TerminalController+BrowserAutomation`, or `CLI+Hooks`. Move each lifecycle behind an owner with a narrow state model: app/window lifecycle, notification routing, CLI dispatch, browser RPC, hook installation. The goal is deleted cross-feature branching, not a same-size set of extension files. - -### J5 — Validate recovery artifacts before object construction - -Introduce a streaming/size-limited snapshot load and a structural validator that caps bytes, windows, workspaces, panels, tree depth, total nodes, and string sizes before any Workspace/TerminalSurface is created. The existing write-time prefixes are useful but cannot defend startup against corrupt, old, or edited files. - -## Oversized-file inventory - -Forty-eight non-test production files cross the 1,000-line bar. No repository document gives them explicit structural waivers. `Resources/mermaid.min.js`, third-party build products, generated Xcode files, and test files are excluded. - -Immediate split candidates: - -- `Sources/AppDelegate.swift` — 11,076 -- `CLI/programa.swift` — 7,895 -- `Sources/TerminalController+BrowserAutomation.swift` — 6,297 -- `Sources/ContentView.swift` — 5,949 -- `CLI/CLI+Hooks.swift` — 4,381 -- `Sources/TabManager.swift` — 3,394 -- `Sources/TerminalController.swift` — 3,149 -- `Sources/Panels/BrowserDataImport.swift` — 3,054 -- `Sources/Panels/BrowserPanel.swift` — 2,803 -- `Sources/TerminalSurface.swift` — 2,788 -- `Sources/SettingsView.swift` — 2,535 -- `Sources/GhosttyApp.swift` — 2,457 -- `Sources/Workspace.swift` — 2,349 - -Still oversized and requiring an explicit cohesion decision before the next feature lands: - -- `Sources/GhosttySurfaceScrollView.swift` 2,796; `Sources/Panels/WebViewRepresentable.swift` 2,251; `CLI/CLI+TmuxCompat.swift` 2,138; `Sources/TabItemView.swift` 2,137; `Sources/ProgramaApp.swift` 2,075; `Sources/BrowserWindowPortal.swift` 2,061; `Sources/Panels/ProgramaWebView.swift` 2,010; `Sources/SessionEscrow.swift` 2,000. -- `Sources/TerminalController+Debug.swift` 1,925; `Sources/ProgramaSettingsFileStore.swift` 1,890; `Sources/SidebarVisuals.swift` 1,878; `Sources/AppDelegate+UITestHarnesses.swift` 1,822; `Sources/Panels/BrowserPanelView.swift` 1,734; `Sources/DebugWindows.swift` 1,694; `Sources/Update/UpdateTitlebarAccessory.swift` 1,639. -- `Sources/SessionWALStore.swift` 1,479; `CLI/CLI+SSH.swift` 1,403; `CLI/CLI+Browser.swift` 1,400; `Sources/TabManager+UITestHarness.swift` 1,366; `Sources/GhosttyTerminalView+Keyboard.swift` 1,233; `Sources/TerminalController+Telemetry.swift` 1,221; `Sources/TerminalController+Workspace.swift` 1,202; `Sources/TerminalController+Surface.swift` 1,165. -- `.github/workflows/ci.yml` 1,154; `Sources/SessionPersistence.swift` 1,126; `Sources/Workspace+Bonsplit.swift` 1,119; `.github/workflows/release.yml` 1,029; `Sources/BrowserWindowHostView.swift` 1,067; `Sources/Panels/Omnibar.swift` 1,061; `Sources/WindowPaneChromePortal.swift` 1,050; `Sources/TerminalWindowPortal.swift` 1,048; `Sources/CommandPaletteSearchEngine.swift` 1,015; `Sources/ClaudeQuotaMonitor.swift` 1,006. -- `Resources/shell-integration/programa-zsh-integration.zsh` 1,261 and `programa-bash-integration.bash` 1,115 are dialect-specific and cohesive, but their duplicated behavior still needs a shared conformance suite rather than a silent waiver. - -## Detailed findings - -### Structural regressions and correctness - -### H1 — Entry points have become subsystem containers - -**Location:** `Sources/AppDelegate.swift:1`, `CLI/programa.swift:1`, `Sources/TerminalController+BrowserAutomation.swift:1`, `Sources/ContentView.swift:1`, `CLI/CLI+Hooks.swift:1`. **Status:** CONFIRMED. - -**Scenario.** A change to notifications, window election, browser focus, terminal routing, or session recovery enters the same 11,076-line `AppDelegate`; CLI protocol, user commands, race orchestration, and transport enter one 7,895-line executable. Reviewers cannot establish a feature's blast radius without reading unrelated state machines, and recent work added another 1,583 lines to `AppDelegate` and 3,896 to browser automation after the 2026-08-27 audit. - -**Direction.** Apply J4. Enforce an owner and file-size budget for new behavior; do not “fix” this by moving identical extensions into arbitrary files. - -### H2 — Socket clients implement mutually incompatible contracts - -**Location:** `daemon/remote/cmd/programad-remote/cli.go:50`, `Sources/TerminalController.swift:1856`, `CLI/programa.swift:542`, `Sources/WorkspaceRemoteCLIRelayServer.swift:28`, `CLI-MCP/MCPSocketBridge.swift:90`. **Status:** CONFIRMED. - -**Scenario A.** Remote `programa ping`, `new-window`, `current-window`, `close-window`, `focus-window`, and `list-windows` select `protoV1` (`cli.go:52-57`). `TerminalController` rejects every non-JSON line as `v1_removed` (`TerminalController.swift:1856-1864`), but `execV1` prints that error and returns 0 (`cli.go:203-212`). Automation sees success for a command that never ran. - -**Scenario B.** The Swift CLI requires `cmux-relay-auth` (`CLI/programa.swift:542-552`); the relay server and Go client use `programa-relay-auth` (`WorkspaceRemoteCLIRelayServer.swift:28`, `cli.go:598`). The Swift remote endpoint cannot authenticate. - -**Scenario C.** `MCPSocketBridge.send` connects and immediately sends the requested method (`CLI-MCP/MCPSocketBridge.swift:90-99`). Password mode requires `auth.login` first (`TerminalController.swift:1433-1492`), making the official MCP surface unusable in that mode. - -**Direction.** Apply J1. Until then, migrate all six commands to v2, make v1 errors nonzero, align the relay identifier, and add an MCP credential/authentication path. - -### H3 — Remote Claude startup trusts a cross-user temporary directory - -**Location:** `daemon/remote/cmd/programad-remote/agent_launch.go:365-402`, `daemon/remote/cmd/programad-remote/agent_launch.go:530-539`. **Status:** CONFIRMED. - -**Scenario.** On a multi-user remote host, another user precreates `/tmp/programa-claude-node-options` as a writable directory and replaces `restore-node-options.cjs` between Programa's atomic rename and Node startup. `ensureClaudeNodeOptionsRestoreModule` neither verifies directory ownership/mode nor rejects symlinks (`agent_launch.go:393-402`); `configureClaudeNodeOptions` adds the path to `NODE_OPTIONS` (`:530-539`). The victim's Claude Node process executes the attacker's JavaScript. - -**Direction.** Put the module in a user-owned `0700` directory under `~/.programa`, verify ownership and non-symlink components, create files with restrictive modes, and open/execute by a verified path. - -### H4 — Remote daemon downloads escape URLSession's file-lifetime contract - -**Location:** `Sources/WorkspaceRemoteSessionController+DaemonInstall.swift:262-351`. **Status:** CONFIRMED. - -**Scenario.** `downloadTask` stores its callback's temporary `localURL`, signals, and returns (`DaemonInstall.swift:300-317`). URLSession may remove that file when the completion handler returns; checksum and move happen afterward (`:318-351`). A normal successful download can therefore fail as “file not found.” Both manifest and artifact waits also discard the semaphore timeout result, so a timed-out callback can mutate captured state after the caller proceeds. - -**Direction.** Move the file to an owned temporary URL inside the completion handler, return an immutable result through one synchronization primitive, cancel on timeout, and never read callback-owned mutable variables after an unchecked wait. - -### H5 — Port settings accept values that trap Swift arithmetic - -**Location:** `Sources/ProgramaSettingsFileStore.swift:671-680`, `Sources/SettingsView.swift:1282-1298`, `Sources/TerminalSurface.swift:196-202`, `Sources/TerminalSurface.swift:1300-1305`. **Status:** CONFIRMED. - -**Scenario.** Settings JSON accepts any positive `Int` for `portBase` and `portRange` (`ProgramaSettingsFileStore.swift:671-680`); the UI adds no range constraint (`SettingsView.swift:1282-1293`). On terminal creation, `base + ordinal * range` and `start + range - 1` use trapping arithmetic (`TerminalSurface.swift:1300-1305`). `Int.max` plus a range of 10 crashes on the first terminal. Process-lifetime `static let` caching at `TerminalSurface.swift:196-202` also contradicts the UI note that new terminals inherit changed values. - -**Direction.** Use one validated port-range value object constrained to 1...65535, use overflow-reporting arithmetic, reject ranges whose end exceeds 65535, and either load per new workspace or disclose restart-required behavior. - -### H6 — Managed settings cross executors into an unsynchronized trust store - -**Location:** `Sources/ProgramaSettingsFileStore.swift:118-134`, `Sources/ProgramaSettingsFileStore.swift:234-248`, `Sources/ProgramaDirectoryTrust.swift:82-149`, `Sources/ProgramaDirectoryTrust.swift:223-227`. **Status:** CONFIRMED. - -**Scenario.** UserDefaults and notification observers call `reapplyManagedSettingsIfNeeded` on whichever queue posts (`ProgramaSettingsFileStore.swift:118-134`). The store lock protects snapshot selection but is released before apply (`:234-248`). Applying trusted-directory settings calls methods that read/write `ProgramaDirectoryTrust.trustedDirectories` and save/post without any lock or actor (`ProgramaDirectoryTrust.swift:82-149,223-227`). Concurrent settings, password, and trust notifications can race dictionary mutation and persistence. - -**Direction.** Give managed settings one executor and make applying a resolved snapshot atomic. Isolate the trust store behind the same actor/serial queue; publish notifications only after committed state is visible. - -### H7 — Recovery input is bounded after, not before, dangerous work - -**Location:** `Sources/SessionPersistence.swift:388-449`, `Sources/TabManager+SessionPersistence.swift:81-94`, `Sources/Workspace+Persistence.swift:96-138`, `Sources/Workspace+Persistence.swift:416-464`. **Status:** CONFIRMED. - -**Scenario.** Startup uses unbounded `Data(contentsOf:)` and full `JSONDecoder` construction for the primary and history snapshots (`SessionPersistence.swift:388-449`). Workspace/window/panel prefixes only apply after decoding (`TabManager+SessionPersistence.swift:81-94`, `Workspace+Persistence.swift:48-50`). The indirect split layout is recursively decoded and rebuilt without depth/node validation (`SessionPersistence.swift:315`, `Workspace+Persistence.swift:416-465`). A corrupt or hand-edited snapshot can consume memory or stack and repeatedly prevent launch before the existing caps help. - -**Direction.** Apply J5. Quarantine invalid snapshots after a bounded failure so the next launch can recover. - -### H8 — iOS request timeouts wait for the request they are supposed to cancel - -**Location:** `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:217-228`, `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:281-298`, `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:481-505`, `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:526-542`. **Status:** CONFIRMED. - -**Scenario.** `withRequestTimeout` races the operation against sleep in a structured task group (`BridgeConnection.swift:281-298`). The operation parks in `withCheckedThrowingContinuation`, stored in `pending` (`:526-542`). Cancelling that child neither removes nor resumes it, and leaving the group waits for all children. Teardown would resume pending requests (`:481-505`) but connect calls teardown only after the timeout helper returns (`:217-228`). An authenticated peer that keeps QUIC open and never answers `system.ping` leaves the app on Connecting indefinitely. - -**Direction.** Make timeout/cancellation own the request ID and atomically remove/resume the continuation. Test a silent peer and assert bounded return plus an empty pending registry. - -### H9 — Mutable actions run with credentials - -**Location:** `.github/workflows/claude.yml:21-38`, `.github/workflows/ci.yml:412`, `.github/workflows/ci-macos-compat.yml:310`. **Status:** CONFIRMED. - -**Scenario.** `anthropics/claude-code-action@v1` receives `CLAUDE_CODE_OAUTH_TOKEN` in a job with `id-token: write`; checkout is also tag-pinned (`claude.yml:21-38`). `upload-artifact@v4` remains tag-pinned in `ci.yml:412` and `ci-macos-compat.yml:310`. A retargeted tag or compromised upstream release executes arbitrary code with the job's token surface. - -**Direction.** Pin every external action to a reviewed 40-character commit SHA, annotate the human version, and add a workflow lint that rejects mutable external `uses:` values. - -### H10 — Remote daemon toolchain is outside Go's support window - -**Location:** `daemon/remote/go.mod:3`. **Status:** CONFIRMED. - -**Scenario.** `daemon/remote/go.mod` declares Go 1.22. As of this audit, Go 1.27 is current and only the two most recent major releases are supported. The daemon imports `net`, crypto primitives, and handles attacker-controlled remote/session inputs, so continuing to build against an unsupported standard-library line misses accumulated security fixes. - -**Direction.** Upgrade at least to the latest 1.26 patch (or 1.27 after migration), pin the CI toolchain, rebuild release assets, and exercise remote compatibility. Sources: [Go release policy and history](https://go.dev/doc/devel/release). - -### Boundary, lifecycle, and dependency findings - -### M1 — iOS newline framing is unbounded and quadratic - -**Location:** `ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift:388-406`, `Sources/MobileBridge/MobileBridgeStreamSupport.swift:17-65`. **Status:** CONFIRMED. - -An authenticated endpoint can send bytes forever without `\n`; `nextBufferedLine` appends 64 KiB chunks and calls `firstIndex` from the start each time (`BridgeConnection.swift:388-406`). Memory is unbounded and scan work is O(n²). Port `MobileBridgeStreamLineReader`'s 8 MiB cap and incremental cursor (`Sources/MobileBridge/MobileBridgeStreamSupport.swift:17-65`). - -### M2 — The shipped mobile identity remains spike-grade - -**Location:** `ios/ProgramaSpike/ProgramaSpike/SecretKeyStore.swift:3-16`, `ios/ProgramaSpike/ProgramaSpike/PairingStore.swift:3-17`. **Status:** CONFIRMED. - -`SecretKeyStore` persists the Iroh private key in UserDefaults and labels the design “spike-grade” (`SecretKeyStore.swift:3-16`); `PairingStore` does the same for the ticket (`PairingStore.swift:3-17`). A container or backup disclosure provides both the stable allowlisted identity and reconnection address. Migrate the key and ticket to Keychain with an explicit device-only accessibility class and atomic migration. - -### M3 — Browser extensions silently receive permanent broad authority - -**Location:** `Sources/Panels/BrowserExtensionManager.swift:111-172`, `Sources/Panels/BrowserPanel.swift:974-978`. **Status:** CONFIRMED. - -Opening the first browser loads every unpacked directory/zip from `~/.config/programa/extensions` and grants every permission/match pattern until `distantFuture` (`BrowserExtensionManager.swift:111-172`). A copied extension with `<all_urls>` silently reads every Programa browser page. Require an enable/consent UI, show requested hosts, support revocation, and default new/changed permissions to denied. - -### M4 — Bounded-input policy is repeatedly applied after allocation - -**Location:** `Sources/Panels/ProgramaWebView.swift:1158-1443`, `Sources/Panels/BrowserHistoryStore.swift:73-180`, `Sources/VSCodeIntegration.swift:289-307`, `Sources/ReviewDiffProber.swift:139-290`. **Status:** CONFIRMED. - -- Context-menu downloads use `Data(contentsOf:)` and URLSession `dataTask` for whole file/network bodies (`ProgramaWebView.swift:1158-1171,1229-1262,1389-1443`). -- Browser history synchronously loads and decodes the whole file, then enforces 5,000 entries only on later mutations (`BrowserHistoryStore.swift:73-75,146-180,233-237`). -- VS Code startup appends output until it finds a URL, with no byte cap (`VSCodeIntegration.swift:289-307,473-505`). -- Review diffs parse full Git output before marking per-file hunks over 400 KiB non-diffable (`ReviewDiffProber.swift:139-184,225-290`). - -Use streaming/file-size gates and enforce limits while reading. One shared bounded collector should make an absent limit impossible. - -### M5 — Escrow holder election has a probe/unlink/bind TOCTOU - -**Location:** `Sources/SessionEscrow.swift:491-509`, `Sources/SessionEscrow.swift:1288-1303`, `Sources/SessionEscrow.swift:1351-1357`. **Status:** PLAUSIBLE. - -Two holders can both fail the connect probe (`SessionEscrow.swift:1351-1357`). Holder A binds; holder B then calls `bindListening`, which unconditionally unlinks the path before bind (`:491-509`), making A unreachable and allowing B to take over. This is interleaving-dependent but the operations are visibly non-atomic. Bind without unlink first, classify `EADDRINUSE`, and only remove a verified stale socket under a lock/election primitive. - -### M6 — Subprocess policy is copied three times - -**Location:** `Sources/GitMetadataProber.swift:495-565`, `Sources/ReviewDiffProber.swift:235-299`, `Sources/GitWorktreeManager.swift:267-334`. **Status:** CONFIRMED. - -`GitMetadataProber`, `ReviewDiffProber`, and `GitWorktreeManager` each own a `Process` + two pipes + semaphore + terminate/SIGKILL sequence (`GitMetadataProber.swift:495-565`, `ReviewDiffProber.swift:235-299`, `GitWorktreeManager.swift:267-334`). Their comments explicitly cite copying. Extract one result type and runner; callers should supply command, timeout, and output ceilings only. - -### M7 — CloudKit's local cache can permanently suppress repair - -**Location:** `ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift:19-81`. **Status:** CONFIRMED. - -After one successful subscription save, `cloudKitSubscriptionSaved` skips every future check (`CloudKitPush.swift:19-45,65-81`). Switching Apple accounts or deleting the server subscription leaves the local boolean true and push silently stops. Reconcile the fixed subscription ID in the current private database and invalidate on `CKAccountChanged`. - -### M8 — iOS reports a transient relay path as final - -**Location:** `ios/ProgramaSpike/ProgramaSpike/PathClassifier.swift:42-55`, `tools/mobile-spike/Sources/iroh-spike/PathClassifier.swift:44-74`. **Status:** CONFIRMED. - -Production returns the first non-unavailable path (`PathClassifier.swift:42-55`), while the spike documents Iroh's relay-first behavior and waits for direct/private settlement. Port that settled-path algorithm and its deterministic snapshot-sequence tests. - -### M9 — Zig has nine version sources but setup validates none - -**Location:** `scripts/setup.sh:12-19`, `scripts/ensure-ghosttykit.sh:44-48`, `ghostty/build.zig.zon:6`. **Status:** CONFIRMED. - -Setup and `ensure-ghosttykit` check presence only (`scripts/setup.sh:12-19`, `scripts/ensure-ghosttykit.sh:44-48`), while Ghostty requires 0.16.0 (`ghostty/build.zig.zon:6`) and nine workflow locations hardcode that value. The currently provisioned 0.15.2 passes setup and fails inside the expensive build. Keep one authoritative version file/helper and consume it in setup and workflows. - -### M10 — TestFlight signing mutates persistent developer state - -**Location:** `scripts/build-ios-testflight.sh:55-112`. **Status:** CONFIRMED. - -`build-ios-testflight.sh` deletes/creates the fixed `ios-build.keychain`, replaces the entire user search list with it, and installs profiles (`:55-112`). There is no EXIT trap. A local run leaves normal keychains undiscoverable, including after the CI-style cleanup deletes the only listed keychain. Use a unique temporary keychain, capture/append/restore the prior list, and remove exact installed artifacts in a trap. - -### M11 — Mobile transport is both linked-unused and reimplemented - -**Location:** `ios/ProgramaSpike/project.yml:5-21`, `tools/mobile-spike/Package.swift:10-35`, `plans/golden-tumbling-gray.md:187-193`. **Status:** CONFIRMED. - -The iOS project and mobile spike link `CmuxIrohTransport`, but their sources import only `IrohLib`; the golden plan explicitly calls Cmux reference-only. The hand-copied implementations have already drifted on path settlement and line bounds. Remove Cmux from compiled dependencies, port the remaining correct spike behavior, then remove the completed executable spike while retaining concise provenance docs. - -### M12 — Two direct dependencies miss material released fixes - -**Location:** `GhosttyTabs.xcodeproj/project.pbxproj:2236-2262`. **Status:** CONFIRMED. - -- Sparkle resolves 2.9.4; 2.9.5 and 2.9.6 add symlink-destination, installer-archive movement, and signature-validation hardening. Upgrade to 2.9.6 after updater tests. Source: [Sparkle 2.x changelog](https://github.com/sparkle-project/Sparkle/blob/2.x/CHANGELOG). -- Iroh is pinned to `1.0.2-cmux.3`; the fork's latest stable is `.8`, adding relay-token continuity, cancellation ownership, idle-path false-demotion fixes, and corrected artifacts. Upgrade deliberately with mobile/remote connection tests. - -Swift Markdown UI 2.4.1, MCP Swift SDK 0.12.1, and swift-toml 2.0.0 are current. Ghostty's pinned SHA matches the Darkroom fork's `main`. Bonsplit is intentionally vendored in-tree. No role-overlap concern survived for those dependencies. - -### M13 — App discovery has conflicting implementations - -**Location:** `scripts/reload.sh:324-345`, `scripts/reloads.sh:156-177`, `scripts/reloadp.sh:10-16`, `scripts/run-tests-v2-ci.sh:43`. **Status:** CONFIRMED. - -`reload.sh` and `reloads.sh` select mtime-sorted builds, `reloadp.sh` uses another search, and CI/smoke scripts take an arbitrary first match. Persistent DerivedData can select a stale app. Use the known DerivedData path in build jobs and one deterministic locator elsewhere. This carries forward the 2026-08-19 N12 finding. - -### M14 — `programa race` can block before it reads Git output - -**Location:** `CLI/programa.swift:5877-5896`. **Status:** CONFIRMED. - -`existingRaceIndexes` connects stdout/stderr pipes, calls `waitUntilExit`, then drains stdout (`CLI/programa.swift:5877-5896`). A repository with enough matching refs fills the pipe, blocks Git, and makes `waitUntilExit` permanent. Use the canonical concurrent-drain runner from J2. - -### M15 — tmux wait signals collide across users and sessions - -**Location:** `daemon/remote/cmd/programad-remote/tmux_waitfor.go:10-20`, `daemon/remote/cmd/programad-remote/tmux_commands.go:509-548`, `CLI/CLI+TmuxCompat.swift:1557-1560`, `CLI/CLI+TmuxCompat.swift:1667-1694`. **Status:** CONFIRMED. - -Both implementations sanitize a caller name into `/tmp/programa-wait-for-<name>.sig` (`tmux_waitfor.go:10-20`, `CLI+TmuxCompat.swift:1557-1560`). No user/session namespace or ownership check exists; Go ignores write/remove errors (`tmux_commands.go:522-544`) and Swift treats a pre-existing file as success (`CLI+TmuxCompat.swift:1675-1691`). Another local user or concurrent Programa session can spoof or consume the signal. Put signals in a user-owned runtime directory and include session identity. - -### M16 — Relay stderr callbacks outlive their process ownership - -**Location:** `Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift:262-321`. **Status:** PLAUSIBLE. - -The readability handler reads on its callback queue and later enqueues a buffer append (`ConnectionOrchestration.swift:262-277`). Teardown clears the handler, pipe, and buffer on the controller queue (`:281-321`) but cannot cancel an append already captured. That append can land after teardown and become the next relay's diagnostic prefix. This needs a generation token or per-process collector before promotion from PLAUSIBLE. - -### M17 — Notification callback isolation is implicit - -**Location:** `Sources/AppDelegate.swift:10301-10340`. **Status:** PLAUSIBLE. - -`userNotificationCenter(_:didReceive:)` directly calls model/AppKit routing and `NSApp.activate` (`AppDelegate.swift:10301-10340`). The delegate callback does not state or enforce a main-queue contract. If UserNotifications invokes it off-main, AppKit and main-owned workspace state are touched from the wrong executor. Add an explicit `Task { @MainActor in ... }`/dispatch boundary and call the completion handler according to the API contract. Status remains PLAUSIBLE because this audit did not instrument callback queues. - -### M18 — iOS reconnect can collapse to one retry - -**Location:** `ios/ProgramaSpike/ProgramaSpike/AppStore.swift:276-306`, `ios/ProgramaSpike/ProgramaSpike/AppStore.swift:420-427`. **Status:** PLAUSIBLE. - -On failure, the phase consumer calls `scheduleReconnect`, which refuses while `reconnectTask` is nonnil (`AppStore.swift:276-306,420-427`). If the failed phase is consumed before the current task reaches its final `reconnectTask = nil`, no successor is scheduled; the UI remains “Reconnecting…” without a task. Replace recursive phase scheduling with one generation-owned backoff loop. A fake connection/clock test should confirm the interleaving. - -### L1 — Pairing recovery text references deleted controls - -**Location:** `ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift:108-114`, `ios/ProgramaSpike/README.md:20-27`. **Status:** CONFIRMED. - -The UI intentionally removed legacy ticket/token fields, but its invalid-paste error and both localizations instruct the user to paste the ticket/token “below”; the README also documents a nonexistent Advanced section. Update the localized error and README to the combined-code flow. - -## Dependency inventory - -| Direct dependency | Pinned/resolved | Current assessment | Disposition | -|---|---:|---|---| -| Sparkle | 2.9.6 | Upgraded and resolved | Keep | -| swift-markdown-ui | 2.4.1 | Current; modern Theme API use | Keep | -| iroh-ffi fork | 1.0.2-cmux.3 | `.8` is stable but its manifest checksum does not match its release asset; `.9-dev.1` is prerelease | Keep `.3` until corrected stable release | -| MCP swift-sdk | 0.12.1 | Current; bridge contract, not SDK, is broken | Keep; fix H2 | -| swift-toml | 2.0.0 | Current; TOMLDecoder use aligns with docs | Keep | -| Bonsplit | vendored | Deliberate in-tree fork | Keep | -| Ghostty | `bccfc833...` | Matches Darkroom fork main at audit time | Keep pinned | -| Go standard library | `go 1.26.7` | Supported patched toolchain line | Keep patched | -| create-dmg | 8.0.0 | Exact isolated build dependency; currency not independently verified | Keep pending release-tool review | - -Context7 was queried for the five direct Swift packages. Official upstream release sources were used for temporal version claims. Dependency versions are time-sensitive; recheck immediately before upgrading. - -## Design tensions - -1. **Incident fixes accumulate in coordinators.** The code has excellent explanations for past races, but each fix adds another flag, generation, timer, or callback to a central object. Weigh lifecycle-specific actors/controllers against the current global-coordinator model. -2. **Recovery prioritizes feature richness over a small trusted core.** Snapshot history, WAL, escrow, reattach, orphan reconciliation, and fresh-spawn fallback interact during launch. Weigh a validated recovery transaction with explicit phases against continuing to add local race guards. -3. **Cross-language contracts are prose.** Swift, Go, MCP, shell integration, and iOS duplicate identifiers and semantics. Weigh schema/code generation or shared conformance fixtures against manual parity. -4. **“Spike” and “shipping” coexist.** The iOS target is named ProgramaSpike and documents shortcuts, yet TestFlight auto-ships it. Decide one quality bar and make the build graph reflect it. -5. **Bounds are caller discipline.** Several systems cap data only after reading/decoding. Weigh typed bounded primitives at trust boundaries against continued per-feature limits. - -## Open questions - -1. Are remote daemon hosts expected to be multi-user? H3 is exploitable in that deployment; even on single-user hosts the path remains an integrity footgun. -2. Is socket password mode intended to support MCP? If not, the MCP command should fail at startup with an explicit unsupported-mode diagnostic rather than every tool failing later. -3. Are browser extensions a user-facing feature or developer-only experiment? Current code loads them in production without an enable switch. -4. Is the iOS app considered production because it auto-ships to TestFlight, or should the workflow be disabled until M1/M2/H8 are resolved? -5. Should session snapshots be treated as user-editable recovery artifacts? The current docs and manual restore affordances imply yes, which makes H7's validator mandatory rather than defensive hardening. - -## Prior-audit reconciliation - -- 2026-08-27 managed-settings race, unbounded session restore, browser download/history bounds, VS Code output, and notification isolation remain and are carried forward above. -- Duplicate-instance termination, Google query routing, Sparkle relaunch, tab-drag equality, `notification.clear` synchronization, bounded socket handle maps/line buffers, mobile revoke, and browser restore transfer were verified as changed and are not re-reported. -- 2026-08-25 current-tip release shipping and rolling-asset ceiling findings are resolved by main-push CI and bounded rolling-candidate archives. -- 2026-08-19 app discovery is resolved by one locator plus job-specific DerivedData roots (M13). The earlier “delete unwired Cmux vendors” recommendation remains narrowed: provenance-only vendor references stay, while M11 removes compiled-unused dependencies and the divergent completed spike. - -## Considered and rejected - -- **Main control-socket blind unlink:** deliberately deferred in current design records; not re-litigated in this codebase audit. -- **The `TerminalController` browser-download `SHORTCUT:`:** its documented concurrency trigger was not proven to have fired, so the marker remains valid debt rather than a finding. -- **Portal duplication:** the former browser/terminal transfer duplication now has `WebKitSubviewTransfer`; the prior structural complaint is resolved. -- **Release provenance and cache publication:** current-tip guards, checksum checks, archive traversal defense, lock ownership, and atomic publish paths survived review. -- **Go daemon package count:** a small stdlib-only daemon is not inherently under-factored; only concrete protocol/security/lifecycle issues are reported. -- **MCP additional JSON properties:** no executable validation bypass survived tracing. -- **`proxy.open` breadth:** current exposure matches the explicit remote-proxy design; no accidental public listener was found. -- **XcodeGen installed by unpinned Homebrew:** reproducibility tension, but no current breakage was proven; not promoted to a finding. -- **Sequential iOS workspace resync:** potentially slow, but no measurement exists; performance speculation is excluded from this codebase audit. -- **Large shell integrations:** their dialect-specific size is cohesive enough to avoid a split demand, but not enough to waive cross-shell conformance testing. - -## Verification handoff - -Every locally actionable finding now has executable runtime or artifact coverage in the landing series. The tagged Debug build passes. Repository policy assigns the suites to GitHub Actions, so the remaining handoff is the required red test-only commit followed by the green implementation commit, CI, and the automatically triggered rolling release. M12's Iroh half must be retried when upstream publishes a corrected stable artifact. diff --git a/docs/ios-testflight-setup.md b/docs/ios-testflight-setup.md deleted file mode 100644 index 499f53f6..00000000 --- a/docs/ios-testflight-setup.md +++ /dev/null @@ -1,236 +0,0 @@ -# Getting the iOS companion onto TestFlight - -One-time setup. The CI lane (`.github/workflows/ios-testflight.yml`) is already -written, verified, and currently skipping every run because the Apple signing -material is not on the repo. Everything below is the human/portal half. - -Tracked in [#203](https://github.com/darkroomengineering/programa/issues/203). - ---- - -## Before starting: two facts to check - -**Team ID must be `ZNHHMX2RP6`.** It is hardcoded at -`.github/workflows/ios-testflight.yml:189`. If the Apple Developer account you -are signed into is a different team, stop — nothing below will work, and the -workflow needs editing first. - -**These three identifiers already exist and must NOT be deleted:** - -| identifier | what it is | -|---|---| -| `com.darkroom.programa` | the shipping macOS app | -| `com.darkroom.programa.spike` | the iOS companion | -| `com.darkroom.programa.spike.widgets` | the widget extension | - ---- - -## Who does what - -**An agent driving the browser can do:** steps 1, 2, 4, 6. - -**A human must do:** steps 3, 5, 7 — they involve a certificate private key, a -`.p12` export password, and an App Store Connect API key. Do not route -credentials, passwords, or key files through an agent. Download them yourself -and paste the secrets into GitHub yourself. - ---- - -## 1. Confirm the iCloud container exists - -<https://developer.apple.com/account/resources/identifiers/list/cloudContainer> - -Look for **`iCloud.com.darkroom.programa`**. - -If it is missing, create it here with exactly that identifier. Xcode cannot -create it later: automatic signing refuses to make a container whose name does -not match the bundle id, so `-allowProvisioningUpdates` will not do it -(`ios/ProgramaSpike/project.yml:114-117`). - -## 2. Set capabilities on the App IDs - -<https://developer.apple.com/account/resources/identifiers/list> - -### `com.darkroom.programa.spike` — listed as "XC com darkroom programa spike" - -Enable **both**: - -- **Push Notifications** -- **iCloud** → Configure → tick **CloudKit** → assign container - `iCloud.com.darkroom.programa` - -Save. - -Why push matters: without it the signed build gets -`aps-environment = development`, and `scripts/build-ios-testflight.sh:110-117` -hard-fails rather than uploading a build that would silently receive no -notifications. At runtime the symptom is `NSCocoaErrorDomain 3000 "no valid -aps-environment entitlement string found for application"`. - -### `com.darkroom.programa` — listed as "Programa" - -**Verify only.** Confirm **iCloud** is enabled and assigned the *same* container -`iCloud.com.darkroom.programa`. The container must be on both App IDs — the Mac -writes the records, the phone reads them -(`ios/ProgramaSpike/project.yml:110-113`). If it is already set, change nothing. - -### `com.darkroom.programa.spike.widgets` - -**Nothing to do.** The widget target declares no entitlements at all -(`ios/ProgramaSpike/project.yml:120-145`) — no push, no iCloud. - -## 3. Get an Apple Distribution certificate — human - -<https://developer.apple.com/account/resources/certificates/list> - -Type must be **Apple Distribution**. A Developer ID or Apple Development -certificate is rejected outright by `scripts/build-ios-testflight.sh:66-73`. - -1. Create or download the certificate, double-click the `.cer` to install it -2. Open **Keychain Access** → My Certificates → right-click it → **Export** -3. Save as `.p12` and set an export password — keep that password, it becomes - `APPLE_IOS_DIST_CERT_PASSWORD` - -## 4. Mint two App Store provisioning profiles - -<https://developer.apple.com/account/resources/profiles/list> - -**Create new ones. Do not reuse the existing profiles** — they were minted -before the iCloud container was attached to the App ID, so they carry no -containers (`plans/golden-tumbling-gray.md:490-493`). A profile bakes in -whatever capabilities exist at creation time, which is why step 2 has to come -first. - -Create both as type **App Store** (distribution), signed with the certificate -from step 3: - -| App ID | file it produces | -|---|---| -| `com.darkroom.programa.spike` | app `.mobileprovision` | -| `com.darkroom.programa.spike.widgets` | widget `.mobileprovision` | - -Download both. - -## 5. Create the App Store Connect app record and API key — human - -### App record - -<https://appstoreconnect.apple.com/apps> → **+** → **New App** - -- Platform: **iOS** -- Bundle ID: **`com.darkroom.programa.spike`** -- Name and SKU: your choice - -**This step is easy to miss.** Nothing in the repo creates this record, it is -not in the issue's secret table, and `altool` rejects the upload without it. - -### API key - -<https://appstoreconnect.apple.com/access/integrations/api> - -**+** → access role **App Manager** → download the `.p8`. - -The `.p8` is downloadable exactly once. Note the **Key ID** and **Issuer ID** -shown on that page. - -## 6. Add all seven repository secrets — human - -<https://github.com/darkroomengineering/programa/settings/secrets/actions> - -Encode the file-based ones: - -```bash -base64 -i dist.p12 | pbcopy # APPLE_IOS_DIST_CERT_BASE64 -base64 -i app.mobileprovision | pbcopy # APPLE_IOS_APP_PROFILE_BASE64 -base64 -i widget.mobileprovision | pbcopy # APPLE_IOS_WIDGET_PROFILE_BASE64 -base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy # APPSTORE_CONNECT_KEY_P8_BASE64 -``` - -| secret | value | -|---|---| -| `APPLE_IOS_DIST_CERT_BASE64` | base64 of the `.p12` from step 3 | -| `APPLE_IOS_DIST_CERT_PASSWORD` | the `.p12` export password, plain text | -| `APPLE_IOS_APP_PROFILE_BASE64` | base64 of the app `.mobileprovision` | -| `APPLE_IOS_WIDGET_PROFILE_BASE64` | base64 of the widget `.mobileprovision` | -| `APPSTORE_CONNECT_KEY_ID` | Key ID, plain text | -| `APPSTORE_CONNECT_ISSUER_ID` | Issuer ID, plain text | -| `APPSTORE_CONNECT_KEY_P8_BASE64` | base64 of the `.p8` | - -Add all seven. A partial set fails fast and names what is missing, but it still -costs a cycle. - -## 7. Dry run - -```bash -gh workflow run ios-testflight.yml -f upload=false -gh run watch --repo darkroomengineering/programa -``` - -Success looks like: the `build` job no longer skipped, and the -"Verifying signed entitlements" step printing all three of - -``` -ok aps-environment = production -ok get-task-allow = false -ok com.apple.developer.icloud-container-environment = Production -``` - -## 8. Ship - -Push any change under `ios/**` to `main`. The upload runs automatically. The -build appears in App Store Connect after processing (usually 5-15 minutes), then -can be assigned to testers. - ---- - -## If step 7 fails - -The failure message names which check failed. - -| message | cause | fix | -|---|---|---| -| `No 'Apple Distribution' identity found` | wrong certificate type | redo step 3 with an Apple Distribution cert | -| `aps-environment='development'` (or absent) | Push was not enabled on the App ID when the profile was minted | redo step 2, then re-mint in step 4 | -| `com.apple.developer.icloud-container-environment` not `Production` | profile predates the container | redo step 4 | -| missing `APPSTORE_CONNECT_*` at preflight | upload credentials absent | finish step 6 | -| `altool` rejects the upload | no App Store Connect app record | do step 5 | - -Note the difference in behaviour by trigger: a **push** with missing secrets -warns and skips so `main` stays green; a **`workflow_dispatch`** with missing -secrets fails hard. - ---- - -## Notes - -- **Signing state is temporary.** `scripts/build-ios-testflight.sh` uses a unique build - keychain, appends it to the existing user search list, and restores that list on every - exit. Provisioning profiles and an existing App Store Connect key are likewise restored; - artifacts created only for the build are removed. -- **Build numbers are already handled.** The lane injects - `GITHUB_RUN_ID` + attempt as `CURRENT_PROJECT_VERSION` - (`.github/workflows/ios-testflight.yml:170-174`), overriding the static `"1"` - committed in `project.yml`. No duplicate-build rejection on later uploads. -- **Already verified present and correct**, so no action needed: - `ITSAppUsesNonExemptEncryption`, `NSCameraUsageDescription` (the QR scanner), - `NSLocalNetworkUsageDescription`, the iPad orientation keys the build script - pre-checks, the 1024px app icon, and `PrivacyInfo.xcprivacy`. -- **Non-blocking gap:** the widget target has no `PrivacyInfo.xcprivacy` of its - own. This will not block a first upload or internal testing, but if the widget - touches a required-reason API it can surface as an ITMS warning and matters - before external testing or review. -- **Bundle id stays `com.darkroom.programa.spike`.** Renaming means a new App - ID, a new App Store Connect record, and re-attaching the iCloud container. - Testers only ever see the display name "Programa". - -## Rules for an agent doing the portal steps - -- **Do not delete any identifier, certificate, profile, or container.** Deletion - is irreversible and invalidates everything signed against it. -- **Do not modify identifiers other than the two named in step 2**, and on - `com.darkroom.programa` only verify — do not change it. -- **Do not handle credentials.** Steps 3, 5, and 6 involve a private key, an - export password, and API keys. Leave those to a human. -- **Stop and report** if an identifier is missing, if the team is not - `ZNHHMX2RP6`, or if a capability cannot be enabled — do not improvise around - it. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 2088f563..45af4e6a 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -137,8 +137,6 @@ Deliberate omissions, not oversights: Programa's browser panels, unrelated to terminal control. A candidate for a later pass. - **Debug methods** (`debug.*`). DEBUG builds only, and mostly UI-test hooks that can synthesize keystrokes and activate the app. -- **Remote sessions** (`workspace.remote.*`). The SSH control plane belongs to the remote - daemon and has its own lifecycle. - **App chrome** (`auth.login`, `settings.open`, `feedback.*`, `markdown.open`) and app-wide test hooks (`app.*`). diff --git a/docs/plans/settings-mcp-tools.md b/docs/plans/settings-mcp-tools.md new file mode 100644 index 00000000..261e82fe --- /dev/null +++ b/docs/plans/settings-mcp-tools.md @@ -0,0 +1,307 @@ +# Settings read/write over the socket and MCP + +Status: planned, 2026-09-02. +Scope: `settings.describe` / `settings.get` / `settings.set` socket methods, the matching +`programa-mcp` tools and a schema resource, docs, and tests. No UI work. +Follow-up to `docs/plans/mcp-server.md`. + +## Correction to the briefing + +`docs/programa-json.md` documents `programa.json`, the command-palette file +(`docs/programa-json.md:1-12`). It is not the reference for `settings.json`. No user-facing +document for `settings.json` exists today — only incidental mentions in +`docs/terminal-themes.md` and `docs/keyboard-shortcuts.md`. Task 8 therefore creates +`docs/settings-json.md` rather than editing `docs/programa-json.md`. + +## Goal and non-goals + +An agent running inside Programa should be able to read the app's current settings, learn the +legal shape of every key from the shipped schema, and change keys deliberately. Today +`Resources/settings.schema.json` is the contract but nothing serves it over the socket, and +`ProgramaSettingsFileStore` is read-only — its single write is the commented-out bootstrap +template (`Sources/ProgramaSettingsFileStore.swift:223-244`). An agent that wants to retune the +terminal has to guess key names and hand-edit the file, which is exactly the failure mode this +plan removes. + +Non-goals: no Settings-window changes, no new preference keys, no shortcut rebinding through +these methods beyond what `shortcuts.bindings` already accepts, no remote-daemon surface, no +`browser.*` expansion. The plan also does not unify the two writers of the sidebar tint defaults +described at `Sources/ProgramaSettingsFileStore.swift:1000-1010`; that conflict is pre-existing +and stays documented rather than fixed here. + +## Functional DAG + +```mermaid +graph LR + subgraph Inputs + SCHEMA[Resources/settings.schema.json] + STORE[ProgramaSettingsFileStore.swift\nparser + apply + watcher] + CAT[V2CommandCatalog.swift] + MCPCAT[CLI-MCP/ToolCatalog.swift\nResourceCatalog.swift] + end + + SCHEMA --> REG[T1 SettingsKeyRegistry\nkey -> section/type/default/writable] + STORE --> REG + REG --> VAL[T2 validator\nvalue vs schema type] + REG --> DESC[T3 settings.describe + settings.get] + STORE --> DESC + VAL --> WRITER[T4 JSONC-preserving writer] + STORE --> WRITER + WRITER --> SET[T5 settings.set + settings.reset] + REG --> SET + DESC --> CATREG[T6 register in V2CommandCatalog] + SET --> CATREG + CAT --> CATREG + CATREG --> TOOLS[T7 SettingsTools.swift + schema resource] + MCPCAT --> TOOLS + TOOLS --> DOCS[T8 docs + CHANGELOG] + WRITER --> UT[T9 unit tests] + VAL --> UT + SET --> E2E[T10 tests_v2 round trip + denied key] + TOOLS --> E2E + DOCS --> VERIFY[T11 CI green + manual agent round trip] + UT --> VERIFY + E2E --> VERIFY +``` + +Parallel batches read off the columns: T1 alone; then T2 and T3 together; then T4; then T5; +then T6; then T7; then T8, T9 and T10 together; then T11. + +## 1. Command design + +All three methods live in a new `Sources/TerminalController+Settings.swift`, dispatched from the +switch in `Sources/TerminalController.swift:1943` and listed in `Sources/V2CommandCatalog.swift` +next to `settings.open` (line 60). None of them touch AppKit, so per the CLAUDE.md threading +policy they run entirely off-main — no `v2MainSync`, unlike `app.reload_config` +(`Sources/TerminalController+System.swift:519-527`). + +**`settings.describe`** — no params, or `{"section": "app"}`. Returns one entry per known key: + +```json +{"keys": [{"path": "app.appearance", "section": "app", "type": "string", + "enum": ["system", "light", "dark"], "default": "system", + "description": "App appearance mode.", "value": "dark", + "source": "file", "writable": true}], + "settings_path": "/Users/x/.config/programa/settings.json", "schema_version": 1} +``` + +`source` is `file` when the key appears in the active settings file, `defaults` when the effective +value comes from `UserDefaults` (a Settings-window edit), and `builtin` when neither wrote it and +the schema default applies. The store already distinguishes the first case: a key present in the +file lands in `managedUserDefaults` or `managedCustomSettings` +(`Sources/ProgramaSettingsFileStore.swift:333-371`), and `applyManagedSettings` records a backup +of the pre-file value under the same identifier set (lines 1011-1073). Expose that distinction +through two new read accessors on the store rather than re-parsing the file in the handler. + +**`settings.get`** — `{"path": "app.appearance"}` or `{"section": "browser"}`. Returns the same +entry shape as `describe`, minus `description` and `enum`, for one key or one section. Exactly one +of `path`/`section` is required; both or neither is `invalid_params`, matching the strictness +`tests_v2/test_jsonrpc_strict_param_validation.py` already enforces for other methods. + +**`settings.set`** — `{"values": {"app.appearance": "dark", "app.terminalOpacity": 0.9}}`. +Dotted paths, so `browser.proxy.port` addresses a nested key. A JSON `null` means "remove +Programa's managed override", which is what the parser already treats `NSNull` as for the +nullable keys (`Sources/ProgramaSettingsFileStore.swift:410-466`). The whole batch is validated +before anything is written; one bad key fails the batch with nothing changed. Response echoes +each path with its `previous` and `value`, plus `"applied": true` once the store has reloaded. + +**`settings.reset`** — worth shipping in v1, as `{"paths": [...]}` only. It is a thin alias for +`settings.set` with `null` for file-managed keys, and it is the only way an agent can undo its own +change without knowing what the value was before. A whole-file reset is not in v1: it would +discard user edits the agent never made. + +Error codes reuse the existing vocabulary. Unknown path or wrong value type is `invalid_params` +with `data: {"path": ...}`. A key on the deny list is `not_supported` with +`data: {"path": ..., "reason": "agent_writable_denied"}`, the same code used for capability +refusals elsewhere (`Sources/TerminalController+Surface.swift:945`). A settings file that fails to +parse is `invalid_state` — the writer must never overwrite a file it could not read. + +File-only versus UserDefaults-only keys: every key in the schema is file-settable by definition, +so `settings.set` has no split. The split appears in `describe`'s `source` field and in the +`writable` flag. Keys that exist in `UserDefaults` but have no schema entry are not addressable at +all; the schema is the contract, and adding a key means adding it to the schema first. + +## 2. Write path decision + +**Recommendation: write the settings file and reload the store synchronously (option a).** + +Writing `UserDefaults` directly (option b) loses on the next reload: `applyManagedSettings` +re-asserts every file-managed key over `UserDefaults` and restores backups for keys that left the +file (`Sources/ProgramaSettingsFileStore.swift:1029-1067`). An agent's direct defaults write would +survive only until the next file touch, and would leave no backup entry, so a later removal would +restore the wrong prior value. Option (c), file plus an explicit `app.reload_config`, makes the +agent do two calls to get a consistent read and leaves a window where `settings.get` reports the +old value. + +Concretely: `settings.set` splices the new values into the file, then calls the store's `reload()` +(line 156) inline and returns the re-read effective values. The watcher +(`Sources/ProgramaSettingsFileStore.swift:111-122`) will also fire, but `reload()` is idempotent +and serialized on `managedSettingsQueue`, so the duplicate is harmless and the response is never a +guess. The Settings window reflects the change through the same path a manual file edit already +uses: `applyManagedSettings` writes `UserDefaults`, and the `@AppStorage` bindings update. + +Concurrent user edits: read the file, compute the splice, and write with +`Data.write(options: .atomic)` plus `0o600`, mirroring the bootstrap writer +(`Sources/ProgramaSettingsFileStore.swift:239-240`). Take the file's modification date before the +read and re-stat before the write; if it moved, fail with `invalid_state` and +`data: {"reason": "file_changed"}` rather than clobbering. This is last-writer-wins avoidance, not +locking, and that is the right level for a config file a human edits by hand. + +Comments: a naive read-modify-write would destroy the file. `JSONCParser.preprocess` strips +comments before `JSONSerialization` (`Sources/ProgramaSettingsFileStore.swift:321-322`), and the +bootstrap template is *entirely* commented-out keys (lines 1499-1523), so re-serializing a +freshly bootstrapped file would delete every hint the user has. The writer therefore edits text, +not a decoded object: locate the target key's value span in the raw string and splice. When the +key is absent it is inserted at the end of its section, and when the section is absent the section +is appended before the closing brace. Comments and unknown keys outside the spliced span are +untouched by construction. + +## 3. Permission model + +There is no per-command permission tier in the socket layer today. Access is decided once per +connection from `accessMode` and the optional password (`Sources/TerminalController.swift:443-508`, +modes at `Sources/SocketControlSettings.swift:7-61`), and the only per-method classification that +exists is the focus-intent allowlist `focusIntentV2Methods` +(`Sources/TerminalController.swift:570-574`). Inventing a second tier for one method family would +add a security-relevant concept with one user. + +So: reads and writes both sit at the existing connection gate, and none of the three methods joins +`focusIntentV2Methods` — changing a setting must never move the user's focus. The real protection +is a deny list, expressed as an `agentWritable: Bool` on each `SettingsKeyRegistry` entry (task 1) +and surfaced in `describe` so an agent learns the boundary instead of discovering it by error. + +Deny (`agentWritable: false`), because each widens the agent's own authority: + +| Path | Why | +|---|---| +| `automation.socketPassword` | Writes the credential file that gates the socket (`Sources/SocketControlSettings.swift:177-201`). | +| `automation.socketControlMode` | Sets the gate itself, up to `allowAll`. | +| `automation.claudeBinaryPath` | Chooses a binary the app then launches. | +| `customCommands.trustedDirectories` | Turns untrusted `programa.json` commands into auto-run ones. | +| `browser.insecureHttpHostsAllowedInEmbeddedBrowser`, `browser.proxy` | Downgrade transport security for the embedded browser. | + +Everything else in the schema is writable, including appearance, fonts, opacity, blur, workspace +colors, notification settings, and shortcut bindings. The deny check runs during batch validation, +before any file read, so a batch containing one denied path changes nothing. + +## 4. MCP surface + +Add `CLI-MCP/Tools/SettingsTools.swift` with three `ProgramaTool` entries built the same way as +`SystemTools` (`CLI-MCP/Tools/SystemTools.swift:6-38`), appended to `ToolCatalog.all` +(`CLI-MCP/ToolCatalog.swift:176-187`). Names: `settings_describe`, `settings_get`, `settings_set`, +plus `settings_reset`. None is focus-stealing, so none takes the `focus_` prefix. Update the +exclusion comment at `CLI-MCP/ToolCatalog.swift:161-175`, which currently lists `settings.open` as +excluded app chrome — that exclusion stands, but the comment must say why `settings.*` is now +partly included. + +Resource: `programa://settings/schema`, a concrete resource in `ListResources` +(`CLI-MCP/ResourceCatalog.swift:22-31`) serving `Resources/settings.schema.json` as +`application/json`, and `programa://settings/current` backed by `settings.describe`. The `host` +switch in `ResourceCatalog.read` (line 59) gains a `"settings"` case with two path segments. +Serving the schema as a resource rather than a tool matters: an agent can pull it once into +context and then write valid values without a round trip per key. + +Tool description shown to agents, one paragraph, on `settings_describe`: + +> Lists every Programa setting with its section, type, default, current value, and where that +> value came from (the settings file, the Settings window, or the built-in default). Read this +> before changing anything, and read `programa://settings/schema` for the full JSON Schema +> including enums and ranges. Settings that would widen this agent's own authority are reported +> with `writable: false` and cannot be changed here. + +## 5. Task DAG + +| # | Task | Files | Est. | +|---|---|---|---| +| 1 | `SettingsKeyRegistry`: one entry per schema key with section, dotted path, type, default, enum, description, `agentWritable`. Generated from `Resources/settings.schema.json` at build time or hand-mirrored with a test that fails on drift. | `Sources/SettingsKeyRegistry.swift` (new, needs 4 pbxproj entries) | 4h | +| 2 | Value validator: path lookup, type and enum and range checks, `null` handling. | `Sources/SettingsKeyRegistry.swift` | 2h | +| 3 | `settings.describe` / `settings.get` handlers plus the two store accessors that expose file-managed versus defaults-managed provenance. | `Sources/TerminalController+Settings.swift` (new), `Sources/ProgramaSettingsFileStore.swift` | 4h | +| 4 | JSONC-preserving splice writer with atomic write, `0o600`, and mtime guard. | `Sources/ProgramaSettingsWriter.swift` (new) | 5h | +| 5 | `settings.set` and `settings.reset` handlers: batch validate, deny check, write, inline `reload()`, echo effective values. | `Sources/TerminalController+Settings.swift` | 3h | +| 6 | Register four methods in the catalog and the dispatch switch. | `Sources/V2CommandCatalog.swift`, `Sources/TerminalController.swift` | 1h | +| 7 | MCP tools and the two settings resources. | `CLI-MCP/Tools/SettingsTools.swift` (new), `CLI-MCP/ToolCatalog.swift`, `CLI-MCP/ResourceCatalog.swift` | 3h | +| 8 | Docs: new `docs/settings-json.md`, a settings section in `docs/mcp-server.md` (tables at lines 114-144), and a `SKILL.md` paragraph so an in-app agent knows the tools exist. Plus `CHANGELOG.md`. | `docs/settings-json.md`, `docs/mcp-server.md`, `SKILL.md`, `CHANGELOG.md` | 3h | +| 9 | Unit tests, see below. | `programaTests/` (new files, pbxproj entries) | 4h | +| 10 | `tests_v2` socket tests, see below. | `tests_v2/` | 3h | +| 11 | CI green, then a manual round trip from an agent inside a tagged build. | — | 1h | + +Total 33h. What moves it: task 1 doubles if the registry is generated from the schema at build +time instead of hand-mirrored, and task 4 doubles if the splice writer has to handle nested +paths inside multi-line JSONC blocks such as `browser.proxy` (assume it does). Every new `.swift` +file needs four manual `project.pbxproj` entries or the build fails with "cannot find type in +scope"; build after each new file, not in a batch. + +## 6. Tests + +Unit (`programaTests/`, run in CI, never locally per the testing policy): + +1. `SettingsKeyRegistryTests` — every schema key resolves; a key removed from the schema fails + lookup; validator accepts and rejects representative values per type, including the boolean + versus integer distinction the store's `jsonBool`/`jsonInt` already enforce + (`Sources/ProgramaSettingsFileStore.swift:1468-1480`); denied paths report `agentWritable: false`. +2. `ProgramaSettingsWriterTests` — build a temp file from + `ProgramaSettingsFileStore.defaultTemplate()`, splice a value, assert the comment lines survive + verbatim and an unrelated unknown key survives; assert insertion into an absent section; assert + the mtime guard rejects a file changed underneath; assert `0o600` on the result. Then construct + a `ProgramaSettingsFileStore` against that temp path, exactly as + `programaTests/WorkspaceUnitTests.swift:1424-1430` already does, and assert the written value + reaches `UserDefaults` through the real reload path. This is behavior through the store, not a + text assertion about source. +3. MCP tool wiring is verified over the wire, not in Swift — `CLI-MCP/ToolCatalog.swift` imports + the MCP SDK, which `programaTests` deliberately does not link + (`programaTests/MCPSocketBridgeTests.swift:14-23`). Bridge-level error mapping for the new + `not_supported` deny response gets a case in `MCPSocketBridgeTests` using its existing mock + listener. + +Socket (`tests_v2/`, CI or a tagged build's socket only): + +4. `tests_v2/test_settings_describe_get_set.py` — `describe` returns a known key with a `source`; + `set` changes `app.appearance`, `get` reflects it, and a follow-up `set` restores the original + so the test leaves no residue; `reset` on the same path clears the file entry. +5. `tests_v2/test_settings_denied_keys.py` — `set` on `automation.socketControlMode` returns + `not_supported` and the effective mode is unchanged afterward; a batch mixing one allowed and + one denied key changes neither. +6. Extend `tests_v2/test_mcp_server_e2e.py` so `tools/list` includes the four settings tools and + `resources/list` includes `programa://settings/schema`. + +## 7. Risks and open questions + +1. **Registry drift from the schema.** The registry duplicates facts the schema already states. + Default: hand-mirror it and add a test that walks `Resources/settings.schema.json` and fails on + any key missing from the registry, so drift breaks CI rather than silently shipping. +2. **Splice writer on hand-mangled files.** Real files contain trailing commas, nested comments, + and duplicate keys. Default: parse-check with `JSONCParser.preprocess` first, and refuse with + `invalid_state` when the file does not round-trip; never rewrite a file the writer does not + fully understand. +3. **Shortcut bindings through `settings.set`.** `shortcuts.bindings` is a free-form map validated + by `KeyboardShortcutSettings.Action` (`Sources/ProgramaSettingsFileStore.swift:850-864`), not by + the schema. Default: accept it, validate action names against `Action.allCases` and stroke + syntax against the existing parser, and reject unknown actions as `invalid_params` rather than + letting them be silently ignored the way file parsing does today. +4. **An agent locking itself out.** Even with the deny list, an agent could set + `app.appearance` or a font that makes the app unusable to the user. Default: accept the risk, + and rely on `settings.reset` plus the fact that every changed key leaves a visible line in a + file the user already owns. +5. **`settings.reset` scope creep.** A full reset is the obvious next ask. Default: keep v1 to + explicit paths and revisit only if someone asks, since a whole-file reset destroys user edits + the agent never made. + +## ADR-001: settings writes go through the settings file, not UserDefaults + +**Status.** Proposed. + +**Context.** `settings.set` has to land a value somewhere that survives, is visible to the user, +and reaches the Settings window. `ProgramaSettingsFileStore` treats the file as authoritative and +re-asserts it over `UserDefaults` on every reload. + +**Options considered.** (A) Write the file and reload the store inline. (B) Write `UserDefaults` +directly. (C) Write the file and require a separate `app.reload_config`. + +**Decision.** Option A. B is overwritten by the store's own apply path and leaves the backup map +inconsistent; C leaves a stale read window and doubles the agent's call count for no gain. + +**Consequences.** A JSONC-preserving text writer is required, which is the largest single task in +this plan. The mtime guard makes concurrent hand-edits fail loudly instead of silently losing. +`settings.get` is consistent immediately after `settings.set` returns. + +Plan complete. Delegate to implementer for execution. diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md deleted file mode 100644 index b0a317c8..00000000 --- a/docs/remote-daemon-spec.md +++ /dev/null @@ -1,219 +0,0 @@ -# Remote SSH Living Spec - -Last updated: March 12, 2026 -Tracking issue: https://github.com/darkroomengineering/programa/issues/151 -Primary PR: https://github.com/darkroomengineering/programa/pull/1296 -CLI relay PR: https://github.com/darkroomengineering/programa/pull/374 - -This document is the working source of truth for: -1. what is implemented now -2. what is intentionally temporary -3. what must be built next - -## 1. Document Type - -This is a **living implementation spec** (also called an **execution spec**): a spec-level document with status tracking (`DONE`, `IN PROGRESS`, `TODO`) and acceptance tests. - -## 2. Objective - -`programa ssh` should provide: -1. durable remote terminals with reconnect/reuse -2. browser traffic that egresses from the remote host via proxying -3. tmux-style PTY resize semantics (`smallest screen wins`) - -## 3. Current State (Implemented) - -### 3.1 Remote Workspace + Reconnect UX -- `DONE` `programa ssh` creates remote-tagged workspaces and does not require `--name`. -- `DONE` scoped shell niceties are applied only for `programa ssh` launches. -- `DONE` context menu actions exist for remote workspaces (`Reconnect Workspace(s)`, `Disconnect Workspace(s)`). -- `DONE` socket API includes `workspace.remote.reconnect`. - -### 3.2 Bootstrap + Daemon -- `DONE` local app probes remote platform, verifies a release-pinned `programad-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. -- `DONE` daemon `hello` handshake is enforced. -- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) plus pushed `proxy.stream.*` events. -- `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. -- `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`). -- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. -- `DONE` SOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes. -- `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. -- `DONE` bootstrap/probe failures surface actionable details. -- `DONE` bootstrap installs `~/.programa/bin/programa` wrapper (also tries `/usr/local/bin/programa`) so `programa` is available in PATH on the remote. - -### 3.5 CLI Relay (Running Programa Commands From Remote) -- `DONE` `programad-remote` includes a table-driven CLI relay (`cli` subcommand) that maps every supported CLI command to the app's v2 JSON protocol; the removed v1 text protocol is never emitted. -- `DONE` busybox-style argv[0] detection: when invoked as `programa` via wrapper/symlink, auto-dispatches to CLI relay. -- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. -- `DONE` relay process uses `-S none` / standalone SSH transport (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=yes` so dead reverse binds fail fast instead of publishing bad relay metadata. -- `DONE` relay address written to `~/.programa/socket_addr` on the remote only after the reverse forward survives startup validation. -- `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.programa/socket_addr` a single time to recover from a stale shared address rewrite. -- `DONE` `programa ssh` startup exports session-local `PROGRAMA_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. -- `DONE` relay startup writes `~/.programa/relay/<relay_port>.daemon_path`; remote `programa` wrapper uses this to select the right daemon binary per session, including mixed local Programa versions. -- `DONE` relay startup writes `~/.programa/relay/<relay_port>.auth` with a relay ID and token; Swift, Go, and the local relay share the `programa-relay-auth` HMAC-SHA256 challenge-response identifier before forwarding any command to the real local socket. -- `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. -- `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. -- `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. - -### 3.6 Artifact Trust -- `DONE` the release workflow publishes `programad-remote` assets for `darwin/linux × arm64/amd64`. -- `DONE` release apps embed a compact `PROGRAMARemoteDaemonManifestJSON` in `Info.plist` with exact asset URLs and SHA-256 digests. -- `DONE` `programa remote-daemon-status` exposes the current manifest entry, local cache verification state, release download command, and GitHub attestation verification command. - -### 3.3 Error Surfacing -- `DONE` remote errors are surfaced in sidebar status + logs + notifications. -- `DONE` reconnect retry count/time is included in surfaced error text (for example, `retry 1 in 4s`). - -### 3.4 Removed Temporary Behavior -- `DONE` removed remote listening-port probe loop and per-port SSH `-L` mirroring. -- `DONE` remote browser routing now uses a single shared local proxy endpoint instead of detected-port mirroring. -- `DONE` remote status now includes structured proxy metadata (`remote.proxy`) and `proxy_unavailable` error code when proxy setup fails. - -## 4. Target Architecture (No Port Mirroring) - -### 4.1 Browser Networking Path -1. `DONE` one local proxy endpoint is created per SSH transport/session key (not per detected port). -2. `DONE` endpoint is provided by a local broker that supports SOCKS5 + HTTP CONNECT and tunnels via daemon stream RPC. -3. `DONE` browser panels in remote workspaces are auto-wired to the workspace proxy endpoint. -4. `DONE` browser panels in local workspaces are not force-proxied. -5. `DONE` identical SSH transports share one endpoint via a transport-scoped broker. - -### 4.2 WKWebView Wiring -1. `DONE` use workspace-scoped `WKWebsiteDataStore(forIdentifier:)`. -2. `DONE` apply workspace/browser scoped `proxyConfigurations`. -3. `DONE` prefer SOCKS5 proxy config. -4. `DONE` keep HTTP CONNECT proxy config as fallback. -5. `DONE` re-apply proxy config on reconnect/state updates. - -### 4.3 Remote Daemon + Transport -1. `DONE` `programad-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) with pushed `proxy.stream.data/eof/error` events. -2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC without polling reads. -3. `DONE` removed remote service-port discovery/probing from browser routing path. - -### 4.4 Explicit Non-Goal -1. Automatic mirroring of every remote listening port to local loopback is not a goal for browser support. - -## 5. PTY Resize Semantics (tmux-style) - -### 5.1 Core Rule -For each session with multiple attachments, the effective PTY size is: -1. `cols = min(cols_i over attached clients)` -2. `rows = min(rows_i over attached clients)` - -This is the `smallest screen wins` rule. - -### 5.2 State Model -Per session track: -1. set of active attachments `{attachment_id -> cols, rows, updated_at}` -2. effective size currently applied to PTY -3. last-known size when temporarily unattached - -### 5.3 Recompute Triggers -Recompute effective size on: -1. attachment create -2. attachment detach -3. resize event from any attachment -4. reconnect reattach - -### 5.4 Correctness Requirements -1. Never shrink history because of UI relayout noise; only PTY viewport changes. -2. On reconnect, reuse persisted session and recompute from active attachments. -3. If no attachments remain, keep last-known PTY size (do not force 80x24 reset). - -## 6. Milestones (Living Status) - -| ID | Milestone | Status | Notes | -|---|---|---|---| -| M-001 | `programa ssh` workspace creation + metadata + optional `--name` | DONE | Covered by `tests_v2/test_ssh_remote_cli_metadata.py` | -| M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing | -| M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors | -| M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior | -| M-004b | CLI relay: run programa commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | -| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | -| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | -| M-007 | Remote proxy stream RPC in `programad-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented | -| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | -| M-009 | PTY resize coordinator (`smallest screen wins`) | DAEMON-ONLY | Daemon session RPC tracks attachments and applies min cols/rows semantics with unit tests — but the app never calls `session.*` yet (audit 2026-08-20, M10); integration is deferred to the detached-sessions plan (`docs/plans/detached-sessions.md`) | -| M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | - -## 7. Acceptance Test Matrix (With Status) - -### 7.1 Terminal + Reconnect - -| ID | Scenario | Status | -|---|---|---| -| T-001 | baseline remote connect | DONE | -| T-002 | identical host reuse semantics | DONE | -| T-003 | no `--name` | DONE | -| T-004 | reconnect API success/error paths | DONE | -| T-005 | retry count visible in daemon error detail | DONE | - -### 7.2 CLI Relay - -| ID | Scenario | Status | -|---|---|---| -| C-001 | `programa ping` from remote session | DONE | -| C-002 | `programa list-workspaces --json` from remote | DONE | -| C-003 | `programa new-workspace` from remote | DONE | -| C-004 | `programa rpc system.capabilities` passthrough | DONE | -| C-005 | TCP retry handles relay not yet established | DONE | -| C-006 | multi-workspace port conflict silent skip | DONE | -| C-007 | ephemeral port filtering excludes relay ports | DONE | - -### 7.3 Browser Proxy (Target) - -| ID | Scenario | Status | -|---|---|---| -| W-001 | remote workspace browser auto-proxied | DONE | -| W-002 | browser egress equals remote network path | DONE | -| W-003 | websocket via SOCKS5/CONNECT through remote daemon | DONE | -| W-004 | reconnect restores browser proxy path automatically | DONE | -| W-005 | local proxy bind conflict yields structured `proxy_unavailable` | DONE | -| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE | -| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE | - -### 7.4 Resize - -Daemon-side semantics only: these scenarios are proven by daemon unit tests and the stdio RPC -test, but no app code calls `session.*` yet — real `programa ssh` terminals do NOT get this -behavior today (audit 2026-08-20, M10). App integration lands with the detached-sessions plan. - -| ID | Scenario | Status | -|---|---|---| -| RZ-001 | two attachments, smallest wins | DAEMON-ONLY | -| RZ-002 | grow one attachment, PTY stays bounded by smallest | DAEMON-ONLY | -| RZ-003 | detach smallest, PTY expands to next smallest | DAEMON-ONLY | -| RZ-004 | reconnect preserves session + applies recomputed size | DAEMON-ONLY | -| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DAEMON-ONLY | - -## 8. Removal Checklist (Port Mirroring) - -Before declaring browser proxying complete: -1. `DONE` remove remote port probe loop and `-L` auto-forward orchestration -2. `DONE` remove mirror-specific routing behavior as default remote behavior -3. `DONE` replace mirroring docker assertions with proxy egress assertions -4. `DONE` keep optional explicit user-driven forwarding out of this path; no automatic mirroring remains in browser routing - -## 9. Open Decisions - -1. Proxy auth policy for local broker (`none` vs optional credentials). -2. Reconnect backoff profile and max retry budget. -3. `DONE` version-scoped `programad-remote` installs under `$HOME/.programa/bin/programad-remote/` are pruned on every fresh install (audit M12): retention is the current version plus the most-recently-used other version, decided by directory mtime rather than version-string comparison. - -## 10. Socket API Contract Notes - -### 10.1 `workspace.remote.configure` Port Fields -1. `port` and `local_proxy_port` accept integer values and numeric strings. -2. Explicit `null` clears each field. -3. Out-of-range values and invalid types (for example booleans/non-numeric strings/fractional numbers) return `invalid_params`. -4. `local_proxy_port` is an internal deterministic test hook to force local bind conflicts in regression coverage. - -### 10.2 SSH Option Precedence -1. `StrictHostKeyChecking` default (`accept-new`) is only injected when no user override is present. -2. Control-socket defaults (`ControlMaster`, `ControlPersist`, `ControlPath`) are only injected when missing. -3. SSH option key matching is case-insensitive for precedence checks in both CLI-built commands and remote configure payloads. - -### 10.3 SSH Docker E2E Harness Knobs -1. `PROGRAMA_SSH_TEST_DOCKER_HOST` sets the SSH destination host/IP used by docker-backed SSH fixtures (default `127.0.0.1`). -2. `PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR` sets the bind address used in fixture container publish mappings (default `127.0.0.1`). -3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack). diff --git a/docs/removed/README.md b/docs/removed/README.md new file mode 100644 index 00000000..499fe615 --- /dev/null +++ b/docs/removed/README.md @@ -0,0 +1,19 @@ +# Removed features + +Each file here records one feature that was deleted from Programa on purpose, so it can be brought back with what we learned the first time. Every doc names the last commit that contained the feature and the exact `git checkout <sha> -- <paths>` command that restores its files. Restoring the files is the easy part; read the "What we learned" section before wiring it back in. + +Reductive pass of 2026-09-02, base commit 903027ccef. Core kept: the Ghostty terminal, workspaces and splits, the sidebar, agent status detection and hooks, notifications, the browser panel and its automation API, the diff review panel, worktrees and race, layouts, the markdown recap panel, the CLI, socket API, and MCP server, updates, session persistence and escrow, the Claude quota footer, and the local tmux-compat CLI. + +| Feature | Doc | Approx. lines removed | +|---|---|---| +| SSH remote workspaces and the Go daemon | [ssh-remote-workspaces.md](ssh-remote-workspaces.md) | 25,600 | +| Mobile bridge, iOS companion, vendored iroh packages | [mobile-bridge-and-ios.md](mobile-bridge-and-ios.md) | 67,000 | +| Browser data import wizard | [browser-data-import.md](browser-data-import.md) | 3,800 | +| Browser extensions | [browser-extensions.md](browser-extensions.md) | 950 | +| Browser developer tools and inspector dock | [browser-developer-tools.md](browser-developer-tools.md) | deferred, not removed; the doc explains why | +| Browser React Grab overlay | [browser-react-grab.md](browser-react-grab.md) | 470 | +| AppleScript support | [applescript.md](applescript.md) | 720 | +| Inline VS Code (serve-web) | [inline-vscode.md](inline-vscode.md) | 610 | +| Custom notification sound files | [custom-notification-sounds.md](custom-notification-sounds.md) | 400 | + +Line counts are git-tracked lines at removal time and include tests and vendored code. diff --git a/docs/removed/applescript.md b/docs/removed/applescript.md new file mode 100644 index 00000000..4ea67cf1 --- /dev/null +++ b/docs/removed/applescript.md @@ -0,0 +1,61 @@ +# AppleScript support + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/AppleScriptSupport.swift Resources/programa.sdef`. + +## What it did + +Programa exposed an AppleScript dictionary (`programa.sdef`) so external scripts and Script +Editor could drive the app: list windows and tabs, read the frontmost window, create new +windows and tabs, send input text to a terminal, split a pane, and quit. Automation was gated +by a `macos-applescript` ghostty config key; when disabled, every AppleScript command returned +a localized "AppleScript is disabled" error instead of running. Third-party automation tools +(Keyboard Maestro, Shortcuts via `osascript`, custom `.scpt` files) were the target audience, +not end users clicking a menu. + +## How it was wired + +- `Info.plist` keys `NSAppleScriptEnabled` (true) and `OSAScriptingDefinition` + (`programa.sdef`) registered the dictionary with the OS. +- `Resources/programa.sdef` declared the suite: `Application`, `Window`, `Tab`, `Terminal` + classes and `perform action`, `new window`, `new tab`, `quit` commands. +- `Sources/AppleScriptSupport.swift` implemented the `NSApplication`/`NSWindow` scripting + bridge classes (`ScriptWindow`, `ScriptTab`, `ScriptTerminal`) and the + `ScriptInputTextCommand` handler, plus `AppleScriptStrings` for the error messages. +- `GhosttyApp.appleScriptAutomationEnabled()` read the `macos-applescript` ghostty config key + and gated every handler; this function had no other caller and was removed with it. +- No menu item, shortcut, or command-palette entry called into this file — it was reachable + only from external AppleScript/`osascript` callers. +- pbxproj: `AppleScriptSupport.swift` was a Sources build-phase member; `programa.sdef` was a + Resources build-phase member. Both PBXBuildFile/PBXFileReference/group entries removed. +- 11 `applescript.error.*` keys removed from `Resources/Localizable.xcstrings`. + +## Files removed and files edited + +Removed: +- `Sources/AppleScriptSupport.swift` +- `Resources/programa.sdef` + +Edited: +- `Resources/Info.plist`: dropped `NSAppleScriptEnabled` and `OSAScriptingDefinition` keys. +- `GhosttyTabs.xcodeproj/project.pbxproj`: dropped the 8 build-file/file-reference/group + entries for the two deleted files. +- `Resources/Localizable.xcstrings`: dropped the 11 `applescript.error.*` string keys. +- `Sources/GhosttyApp.swift`: dropped the now-unused `appleScriptAutomationEnabled()` helper. + +## What we learned + +No test file existed for this feature and no CHANGELOG.md entry mentions AppleScript by name, +so there is no recorded bug history or design rationale to carry forward. No docs/audits entry +mentions AppleScript. The one design signal in the removed code itself: automation was +opt-in via a ghostty config key rather than a Settings toggle, and every failure path returned +a typed `enum` error with a specific localized message (missing action, missing target, window +unavailable, etc.) rather than a generic failure — a reasonable pattern if this is rebuilt. + +## Why removed and what a future version should do differently + +The user's reductive pass drops anything not essential to Programa's core terminal/browser +experience; AppleScript automation is a power-user integration surface with no evidence of +use (no tests, no changelog mentions, no referencing UI). If rebuilt, prefer exposing the same +capabilities through the existing socket/CLI/MCP command surface (`V2CommandCatalog`) instead +of a second, parallel automation API — that keeps one source of truth for "what can drive +Programa from outside the app" instead of two (AppleScript dictionary vs. socket commands). diff --git a/docs/removed/browser-data-import.md b/docs/removed/browser-data-import.md new file mode 100644 index 00000000..42b5244e --- /dev/null +++ b/docs/removed/browser-data-import.md @@ -0,0 +1,80 @@ +# Browser data import wizard + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/Panels/BrowserDataImport.swift Sources/Panels/BrowserImportWizardView.swift programaTests/BrowserImportMappingTests.swift programaUITests/BrowserImportProfilesUITests.swift`. + +## What it did + +A three-step wizard let a user pick an installed browser (Safari, Chrome, Firefox, Arc, Brave, +Edge, and about 20 others), choose which of its profiles to pull from, and import cookies, +history, or both into a Programa browser profile. It scored each installed browser's on-disk +profile directories to decide what to offer, supported merging multiple source profiles into one +destination or keeping them separate, and showed a summary of what was imported. A dismissible +"Import browser data" hint chip appeared on blank browser tabs pointing users at the same flow, +with its own visibility toggle in Settings > Browser > Data. + +## How it was wired + +Entry points: "Import Browser Data…" menu item (View menu), a button in Settings > Browser > Data, +a button in the browser profile popover menu, a toolbar hint chip on blank tabs, and a sidebar help +menu item. All of them called `BrowserDataImportCoordinator.shared.presentImportDialog()`. +Settings keys: `browserImportHintShowOnBlankTabs` (default true) and `browserImportHintDismissed` +(default false), also configurable via `settings.json`'s `browser.showImportHintOnBlankTabs`. +UI-test hooks: `PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW`, `_DISMISSED`, `_OPEN_BLANK_BROWSER`, +`_OPEN_SETTINGS`, `_AUTO_OPEN` env vars in `AppDelegate.swift`. `SettingsNavigationTarget.browserImport` +routed Settings deep links to the Browser tab. No socket/CLI/MCP command existed for this feature. + +## Files removed and files edited + +Removed: `Sources/Panels/BrowserDataImport.swift`, `Sources/Panels/BrowserImportWizardView.swift`, +`programaTests/BrowserImportMappingTests.swift`, `programaUITests/BrowserImportProfilesUITests.swift`. + +Added: `Sources/Panels/BrowserAvailability.swift` — extracted from `BrowserDataImport.swift` before +deletion. It holds `BrowserAvailability` and the parts of `InstalledBrowserDetector` it depends on +(`allBrowserDescriptors`, `resolveApplicationPresence`), because the `app.browsers` socket command +(`TerminalController+System.swift`) and the `PROGRAMA_DEFAULT_BROWSER`/`PROGRAMA_DEFAULT_BROWSER_BUNDLE_ID` +env vars (`TerminalSurface.swift`) depend on that code and are not part of this feature — they stay. + +Edited: `Sources/SettingsView.swift` (dropped the whole "Import Browser Data" block from the Data +section, its `@AppStorage`/`@State` properties, and the hint-visibility helpers), `Sources/ProgramaApp.swift` +(menu item, `SettingsNavigationTarget.browserImport`, a stale debug-window identifier), +`Sources/AppDelegate.swift` (UI-test env var hooks, still keeps the general "force a window" UI-test +safety net), `Sources/SidebarVisuals.swift` (help menu item), `Sources/Panels/BrowserPanelView.swift` +(blank-tab hint chip, its state, and the profile-menu import action), `Sources/Panels/BrowserToolbarViews.swift` +(profile-menu import button, `BrowserImportHintContentView`), `Sources/Panels/BrowserSettings.swift` +(`BrowserImportHint*` types/settings), `Sources/DebugWindows.swift` (stray label in a debug preview), +`Sources/ProgramaSettingsFileStore.swift` and `Resources/settings.schema.json` (`showImportHintOnBlankTabs` +config key), `Sources/SettingsModels.swift` (`SettingsTab.owning` switch), `programaTests/GhosttyConfigTests.swift` +(`BrowserInstallDetectorTests`, `BrowserImportScopeTests`), `docs/v2-api-migration.md` (pointed the +`app.browsers` doc at the new file, dropped the reference to the removed wizard). +`Sources/Panels/BrowserProfileStore.swift` was not touched — none of its members existed only for import. + +## What we learned + +CHANGELOG.md records one correctness fix in this code: Unicode domains and their Punycode forms +were treated as different filters, so internationalized domains silently imported zero matching +cookies or history entries until fixed. A future re-implementation needs to normalize domains +before filtering, not after. + +`docs/audits/codebase-audit-2026-08-31.md` does not mention the import wizard specifically. No +audit finding was found for this feature; if one exists it was not surfaced by this search. + +The code's own comments document two deliberate boundaries worth keeping in a rebuild: +`BrowserAvailability` (installed/running browser detection for `app.browsers`) was kept +independent of `InstalledBrowserDetector`'s leftover-profile-data scoring specifically so that +adding a browser to one list never changes what the other offers. `BrowserImportPlanResolver`'s +`realize(plan:)` and `BrowserDataImporter.importData(...)` were documented as a frozen call +contract for the SwiftUI wizard, evidence that an AppKit-to-SwiftUI port of this wizard happened +once already (`BrowserImportWizardView.swift`'s header comment) and preserved that contract rather +than reworking it. + +## Why removed and what a future version should do differently + +The wizard was a large (3,000+ line), self-contained, low-traffic feature: browser cookie/history +import is a one-time setup action, not a daily workflow, and its surface (three-step flow, source +profile detection, separate-vs-merged destination modes) was disproportionate to that usage. It +also grew a whole secondary promotional UI (the blank-tab hint chip, its own settings, its own +dismissal state) to get users to notice it. + +If this comes back, keep it much smaller: a single "Import from Browser…" action with sane +defaults (merge into current profile, cookies + history) and drop the separate-profiles/merge-mode +choice and the hint-chip promotion machinery unless usage data shows people need them. diff --git a/docs/removed/browser-developer-tools.md b/docs/removed/browser-developer-tools.md new file mode 100644 index 00000000..c8531620 --- /dev/null +++ b/docs/removed/browser-developer-tools.md @@ -0,0 +1,71 @@ +# Browser developer tools and the hosted inspector dock + +**Status: NOT removed.** This sub-feature was scoped for removal in the same pass as browser data +import, browser extensions, and React Grab, but the implementer stopped before touching it and is +reporting back instead of guessing. Nothing under this heading has been deleted; this doc records +why, so the next attempt does not repeat the discovery work. + +## What it does (still present) + +Cmd+Option+I (Safari default) toggles WebKit's native Web Inspector attached to (or detached from) +a browser panel's page. A palette command, a `showBrowserJavaScriptConsole` shortcut, and an +AppDelegate-routed shortcut all reach it. When docked, the inspector shares the same NSView +hierarchy as the page content, and the app actively manages the divider between them (drag-resize, +side detection, layout reflow on window/pane changes). The automation API and this feature are +independent — nothing under `surface.browser.*` depends on `toggleBrowserDeveloperTools`. + +## Why the implementer stopped + +The prompt estimated the hosted-inspector code as roughly lines 67-772 of +`Sources/Panels/WebViewRepresentable.swift`. Grepping the actual file found `inspector`-related +identifiers as late as line 2160 of a 2251-line file — the docking/geometry logic is not confined +to a clean sub-range, it is threaded through the same `HostContainerView` coordinator code that +positions and resizes the page's own WKWebView. `Sources/BrowserWindowHostView.swift` (1,067 +lines) is almost entirely one class, `WindowBrowserHostView`, that both hosts the browser page +portal (a file explicitly listed as must-keep-working for this cluster) and manages inspector dock +geometry — the two are not separable into distinct files or extensions the way React Grab's panel +routing was. `Sources/BrowserWindowPortal.swift` (2,061 lines) and `Sources/WebKitSubviewTransfer.swift` +are in the same position. `Sources/Panels/InspectorDock.swift` is a small (116-line) namespace of +pure geometry/detection helpers, but it is called from all three of the files above, so deleting it +requires reworking call sites inside code paths explicitly marked as must-stay-working, not just +deleting a self-contained file. + +`Sources/Panels/BrowserPanel+DeveloperTools.swift` (707 lines) is itself a `BrowserPanel` extension +that calls into `InspectorDock` and into members defined on `WindowBrowserHostView` +(`setPreferredHostedInspectorWidth`, `setHostedInspectorFrontendWebView`, +`scheduleHostedInspectorDividerReapply`, `scheduleHostedInspectorDockConfigurationSync`) — deleting +it without also removing those `WindowBrowserHostView` members leaves dead public API on a +must-keep-working class; removing those members requires editing the geometry/layout code in +`BrowserWindowHostView.swift` directly. + +Given the size (roughly 6,300 combined lines across the five files above) and the risk of +regressing the core browser panel — which this cluster's brief explicitly requires to keep working +— the implementer judged this outside what could be done reliably without extensive manual and +automated testing of browser panel layout, focus, and resize behavior, and stopped per the +brief's own guardrail: "STOP and report instead of guessing when... a removal would change the +behavior of a feature outside this cluster." + +## What a future attempt should do differently + +1. Budget this as its own task, separate from the other three sub-features in this cluster — it is + larger than all three combined. +2. Start from `Sources/Panels/InspectorDock.swift`'s own doc comment, which already names every + call site that duplicated its logic before consolidation (`BrowserPanel.swift`, + `BrowserPanelView.swift`'s `WebViewRepresentable.Coordinator.HostContainerView`, and + `BrowserWindowPortal.swift`'s `WindowBrowserHostView`) — that comment is close to a complete map + of what needs to change. +3. Decide up front whether to (a) keep WebKit's native Web Inspector fully wired but delete only + the app's UI entry points (shortcut, palette command, toolbar button), leaving the dock-geometry + code in place as dead-but-safe, or (b) do the full removal including geometry code. Option (a) + is much lower risk and still satisfies "remove everything not essential" from the user's + perspective, since WebKit's own right-click "Inspect Element" is the only remaining way in. +4. If doing (b), write or run the existing `BrowserPanelTests.swift` `WindowBrowserHostViewTests`, + `BrowserPanelHostContainerViewTests`, and `BrowserWindowPortalLifecycleTests` classes before and + after each edit — they are the closest thing to a regression safety net for this code. + +## Related audit note + +`docs/audits/codebase-audit-2026-08-31.md`'s "Considered and rejected" section notes: "Portal +duplication: the former browser/terminal transfer duplication now has `WebKitSubviewTransfer`; the +prior structural complaint is resolved." This confirms `WebKitSubviewTransfer.swift` is shared +portal infrastructure, not inspector-specific, reinforcing that it should not be deleted wholesale. diff --git a/docs/removed/browser-extensions.md b/docs/removed/browser-extensions.md new file mode 100644 index 00000000..2a77d40c --- /dev/null +++ b/docs/removed/browser-extensions.md @@ -0,0 +1,63 @@ +# Browser extensions + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/Panels/BrowserExtensionManager.swift Sources/Panels/BrowserExtensionAdapters.swift`. + +## What it did + +Programa's embedded browser could load WebExtensions (Chrome/Safari-style browser extensions) +from `~/.config/programa/extensions/` — unpacked directories or ZIP archives — using WebKit's +native `WKWebExtensionController`. A puzzle-piece toolbar button opened a management popover that +listed installed extensions and let the user enable, disable, or revoke them. This shipped as a +proof of concept (the code's own comment called it that). + +## How it was wired + +Entry point: a puzzle-piece toolbar button in the browser panel (`browser.extensions.manage`), +gated `@available(macOS 15.4, *)` since `WKWebExtensionController` requires that OS version. +`BrowserExtensionManager.shared` was wired into `BrowserPanel`'s WebView configuration +(`configureWebViewConfiguration`), tab lifecycle (`registerTab`/`unregisterTab` on setup/close), +and active-tab tracking (`Workspace+Bonsplit.swift`'s selection funnel called `noteTabActivated`). +No settings key, shortcut, command-palette entry, or socket/CLI/MCP command existed for this +feature — it was reachable only through the toolbar button. + +## Files removed and files edited + +Removed: `Sources/Panels/BrowserExtensionManager.swift`, `Sources/Panels/BrowserExtensionAdapters.swift`. + +Edited: `Sources/Panels/BrowserPanel.swift` (dropped the `webExtensionController` wiring in +`configureWebViewConfiguration`, and the `registerTab`/`unregisterTab` calls), `Sources/Panels/BrowserPanelView.swift` +(dropped the puzzle-piece toolbar button and its call site), `Sources/Workspace+Bonsplit.swift` +(dropped the `noteTabActivated` call in the pane-selection funnel), `programaTests/BrowserConfigTests.swift` +(dropped `BrowserExtensionConsentStoreTests`, restored a shared test helper actor that had been +defined between that class and the next one), `Resources/Localizable.xcstrings` (dropped 12 +`browser.extensions.*` keys: change/consent.message/consent.title/disabled/enable/enabled/manage/ +manage.message/manage.title/none.message/none.title/noneRequested). + +## What we learned + +`docs/audits/codebase-audit-2026-08-31.md` finding M3 (medium severity, confirmed, status +RESOLVED at audit time) found that installed extensions received every requested permission and +host match pattern permanently, with no consent step: "Opening the first browser loads every +unpacked directory/zip from `~/.config/programa/extensions` and grants every permission/match +pattern until `distantFuture`. A copied extension with `<all_urls>` silently reads every Programa +browser page." The audit's fix required an enable/consent UI, visible requested-hosts display, +revocation support, and default-deny for new/changed permissions — CHANGELOG.md confirms this +shipped: "Browser extensions now require explicit permission consent and support revocation." + +The same audit's open questions section asked directly: "Are browser extensions a user-facing +feature or developer-only experiment? Current code loads them in production without an enable +switch." That question was never answered before this removal — the feature stayed a proof of +concept from introduction to removal, with no settings-visible on/off switch of its own beyond the +per-extension consent added for M3. + +## Why removed and what a future version should do differently + +The feature never left proof-of-concept status: no discovery UI beyond a filesystem convention, no +extension store or install flow, and a real security finding (M3) that had to be patched onto it +after the fact rather than designed in. It also only worked on macOS 15.4+, splitting the toolbar +UI with an availability check for a feature most users never used. + +A future version should treat extension support as security-sensitive from the start — permission +consent, host-access visibility, and revocation designed in before shipping, not added after an +audit — and should decide up front whether this is a user-facing feature (with a discovery/install +UI) or stays out of the product entirely. diff --git a/docs/removed/browser-react-grab.md b/docs/removed/browser-react-grab.md new file mode 100644 index 00000000..342ff1bd --- /dev/null +++ b/docs/removed/browser-react-grab.md @@ -0,0 +1,86 @@ +# React Grab + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/Panels/ReactGrab.swift`. + +## What it did + +React Grab was a click-to-select element picker injected into the embedded browser's page (via the +open-source `react-grab` npm package, fetched over the network and integrity-checked against a +pinned SHA-256 hash). A toolbar button or Cmd+Shift+G let a user pick a page element; the picker +copied a description of the selection and, when triggered from a terminal panel with exactly one +browser panel in the workspace, pasted that description into the terminal ("pasteback"). It shared +its round-trip architecture (panel routing, WKScriptMessageHandler bridge, NotificationCenter +pasteback) with Design Mode, which was modeled on it and is not removed. + +## How it was wired + +Entry points: a toolbar button in the browser panel, the `toggleReactGrab` keyboard shortcut +(default Cmd+Shift+G), a "Toggle React Grab" View-menu item, and a `palette.browserReactGrab` +command-palette command. Routing: `TabManager.toggleReactGrabFromCurrentFocus()`, called from +`AppDelegate`'s shortcut dispatch and the command palette. One automation-adjacent mention: a doc +comment in `TerminalController+BrowserAutomation.swift` describing Design Mode's routing as +"the same rule React Grab's keyboard shortcut uses" — no actual socket/CLI/MCP command existed for +React Grab itself. Settings: no persisted settings key (version pin and hash list were compiled +constants in `ReactGrabSettings`). + +## Files removed and files edited + +Removed: `Sources/Panels/ReactGrab.swift`. + +Moved (not removed): `ReactGrabShortcutPanelSnapshot`, `ReactGrabShortcutRoute`, and +`resolveReactGrabShortcutRoute` moved from `ReactGrab.swift` into `Sources/Panels/DesignMode.swift` +before deletion, because Design Mode's `activateDesignModeRoute(in:)` depends on that same +panel-routing rule (focused browser routes directly; focused terminal routes to the workspace's +single browser panel). The identifiers keep their React-Grab-derived names since Design Mode +already referenced them extensively; a comment now explains the origin. + +Edited: `Sources/Panels/BrowserPanel.swift` (dropped `isReactGrabActive`, `reactGrabMessageHandler`, +and related `@Published` state, and the `setupReactGrabMessageHandler`/`ReactGrabScriptLoader.prefetch()` +call sites), `Sources/Panels/BrowserPanelView.swift` (dropped the toolbar button and its +`resetReactGrabState` call on URL change), `Sources/TabManager.swift` (dropped +`toggleReactGrabFromCurrentFocus`, kept the sibling `toggleDesignModeFromCurrentFocus`), +`Sources/AppDelegate.swift` (dropped the `handleReactGrabDidCopySelection` notification handler +and its observer registration, the `toggleReactGrab` shortcut-action routing; left the shared +`sendTextWhenReady` pasteback function and its `isReactGrabPasteback`-named debug logging alone — +Design Mode uses that same function), `Sources/TerminalController.swift` (dropped the +`.reactGrabDidCopySelection` notification name), `Sources/ContentView.swift` and +`Sources/ContentView+CommandPalette.swift` (dropped the `palette.browserReactGrab` command), +`Sources/KeyboardShortcutSettings.swift` and `Resources/settings.schema.json` (dropped the +`toggleReactGrab` shortcut action), `Sources/ProgramaApp.swift` (dropped the View-menu item), +`Sources/TerminalController+BrowserAutomation.swift` (reworded a doc comment that referenced React +Grab as a still-existing feature), `Resources/Localizable.xcstrings` (dropped 3 keys: +`browser.reactGrab`, `menu.view.toggleReactGrab`, `shortcut.toggleReactGrab.label`), +`programaTests/ShortcutAndCommandPaletteTests.swift` (kept `ReactGrabShortcutRouteTests` — it +tests the routing function, which still exists and now backs Design Mode; dropped the two +`ReactGrabPastebackTargetTests` methods that called the removed `toggleReactGrabFromCurrentFocus`), +`programaTests/BrowserPanelTests.swift` (dropped `BrowserPanelReactGrabBridgeTests`), +`programaTests/BrowserConfigTests.swift` and `programaTests/AppDelegateShortcutRoutingTests.swift` +(dropped React-Grab-only shortcut default/routing tests). + +`Sources/Panels/DesignMode.swift` was not removed and was not otherwise changed beyond the moved +routing helper — it keeps its own `armDesignModeRoundTrip`/`toggleOrInjectDesignMode` etc., +separate from React Grab's equivalents, and remains reachable from `toggleDesignModeFromCurrentFocus`, +the `palette.browserDesignMode` command, and the `surface.browser.design_mode.toggle` automation +command. Design Mode had no in-app keyboard shortcut of its own before this removal (it shared no +actual shortcut binding with React Grab, despite the code comment describing them as parallel), so +no new shortcut was added. + +## What we learned + +No CHANGELOG.md entry and no audit finding mentions React Grab by name. The code's own comments +are the only source of design rationale: the pasteback flow explicitly distinguished "focused +browser panel, no return target" from "focused terminal panel, route to the workspace's single +browser panel, remember where to paste back" — and refused to route at all when zero or more than +one browser panel existed in the workspace, to avoid guessing. That routing rule is exactly what +Design Mode reused and is the reason it could not be deleted outright. + +## Why removed and what a future version should do differently + +React Grab depended on fetching a third-party npm package's script over the network at runtime +(pinned by hash, but still an external dependency for a core interaction), and Design Mode already +covers the same "point at something on the page and bring it into the terminal" use case without +that network dependency. Keeping both was duplicate surface for one job. + +If element-picking from a fetched script is wanted again, prefer vendoring the script (no runtime +fetch) or keep it entirely inside Design Mode's self-contained picker rather than reintroducing a +second parallel implementation. diff --git a/docs/removed/custom-notification-sounds.md b/docs/removed/custom-notification-sounds.md new file mode 100644 index 00000000..e0fef86c --- /dev/null +++ b/docs/removed/custom-notification-sounds.md @@ -0,0 +1,82 @@ +# Custom notification sound files + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/NotificationSoundStaging.swift Sources/SettingsView.swift Sources/TerminalNotificationStore.swift Sources/ProgramaSettingsFileStore.swift Resources/settings.schema.json programaTests/NotificationAndMenuBarTests.swift` and then re-diff against the current (slimmed) versions of those files, since only `NotificationSoundStaging.swift` was a whole-file replacement — the rest were partial edits. + +## What it did + +Settings > Notifications let a user pick "Custom File..." from the sound dropdown, choose any +local audio file (any format `NSOpenPanel` would show for `.audio`), and Programa staged a +playable copy under `~/Library/Sounds`. If the source was already WAV/AIFF/CAF it was copied +as-is; anything else (mp3, m4a, etc.) was transcoded to CAF via `afconvert`. Staged files were +content-addressed by a hash of the source path so re-choosing the same file reused the staged +copy, and a JSON sidecar (`.source-metadata`) recorded the source's size/mtime/inode so the +staging logic could detect the source changing and re-stage. A "Choose..." button ran the +panel and transcode; "Clear" reset the path; a status line under the picker showed +staging progress/errors ("Ready for notifications." / "Prepared for notifications (converted +to CAF)." / specific failure messages for missing file, missing extension, or transcode +failure). The system-sound picker (Basso, Glass, Ping, etc., plus "Default" and "None") is +unaffected and stays. + +## How it was wired + +- Settings: `@AppStorage(NotificationSoundSettings.key)` (`"notificationSound"`, values + `"default"`, a system sound name, `"custom_file"`, or `"none"`) plus + `@AppStorage(NotificationSoundSettings.customFilePathKey)` + (`"notificationSoundCustomFilePath"`, default `""`) for the chosen file's path. +- `settings.json` config key: `notifications.customSoundFilePath` (string, default `""`), and + `notifications.sound` accepted `"custom_file"` as an enum value. +- No menu item, shortcut, or command-palette entry — Settings UI only. +- `Sources/NotificationSoundStaging.swift` (`NotificationSoundSettings`) owned staging, + transcoding, playback preview, and stale-file cleanup logic; `Sources/SettingsView.swift` was + the only UI consumer; `Sources/TerminalNotificationStore.swift` called + `NotificationSoundSettings.sound()` to build the `UNNotificationSound` used for real + deliveries and `playSelectedSound()` for the in-app suppressed-notification feedback beep — + both already funnel through the slimmed system-sound-only implementation, so they needed no + code changes. + +## Files removed and files edited + +Removed: none (no whole file was custom-file-only; `NotificationSoundStaging.swift` was +rewritten in place rather than deleted, since it also holds the unrelated "run a custom shell +command on notification" feature, which stays). + +Edited: +- `Sources/NotificationSoundStaging.swift`: rewritten to keep only `key`/`defaultValue`, + `systemSounds` (dropped the `"Custom File..."` entry), `sound()`, `usesSystemSound()`, + `isSilent()`, `playSelectedSound()`/`previewSound()`, and the untouched + `customCommandKey`/`runCustomCommand` shell-command feature. Cut staging, transcoding, + preview-from-path, metadata sidecar, and cleanup logic (~370 lines). +- `Sources/SettingsView.swift`: dropped the `notificationSoundCustomFilePath` `@AppStorage`, + the file-chooser/status/clear UI block, `chooseNotificationSoundFile()`, + `refreshNotificationCustomSoundStatus()`, `notificationCustomSoundIssueMessage()`, + `notificationCustomSoundReadyStatusMessage()`, the custom-sound-error `.alert`, and the + related `@State` vars; kept the system-sound `Picker` and preview button. +- `Sources/ProgramaSettingsFileStore.swift`: dropped parsing/writing the + `notifications.customSoundFilePath` config key (both the reader and the default-settings + writer). +- `Resources/settings.schema.json`: dropped the `customSoundFilePath` property and the + `custom_file` enum value from `notifications.sound`. +- `programaTests/NotificationAndMenuBarTests.swift`: removed 7 tests exercising staging, + transcoding, tilde-expansion, explicit-selection, and failure paths; trimmed + `testNotificationSoundDisablesSystemSoundForNoneAndCustomFile` to + `testNotificationSoundDisablesSystemSoundForNone` (kept the `"none"` assertion, dropped the + `custom_file` assertion). + +## What we learned + +CHANGELOG.md has no entry mentioning custom notification sound files, and neither audit doc +(`docs/audits/codebase-audit-2026-08-03.md`, `docs/audits/codebase-audit-2026-08-31.md`) +mentions this feature — there is no recorded bug history or security finding to carry forward. +The one design detail worth keeping if this is rebuilt: staged files were content-addressed by +a hash of the source path (not the file bytes), with a metadata sidecar for change detection, +so switching between two different source files never collided on disk, and re-selecting the +same file was a no-op after the first transcode. + +## Why removed and what a future version should do differently + +Custom audio files added a real subprocess dependency (`afconvert`), a staging directory +under `~/Library/Sounds` that needed its own cleanup logic, and a multi-state status UI, all +for a preference most users leave on a system sound. If rebuilt, scope it down: skip +transcoding entirely and only accept formats `UNNotificationSound` already supports natively +(WAV/AIFF/CAF), which removes the `afconvert` dependency and the async staging queue while +keeping the "pick your own file" capability. diff --git a/docs/removed/inline-vscode.md b/docs/removed/inline-vscode.md new file mode 100644 index 00000000..e004b993 --- /dev/null +++ b/docs/removed/inline-vscode.md @@ -0,0 +1,90 @@ +# Inline VS Code (serve-web) + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/VSCodeIntegration.swift programaTests/ServeWebPortStoreTests.swift`, then re-apply the vscodeInline enum case, panel/menu/palette wiring, and tests removed below (they no longer exist as a clean checkout target since they were edits, not whole-file additions). + +## What it did + +"Open Folder in VS Code (Inline)" launched `code serve-web` in the background and opened the +resulting web UI inside a Programa browser split, so a VS Code editor could sit next to a +terminal without leaving the app or opening a separate window. It reused the same server +process and port across restarts (persisted to Application Support), reused a persistent +connection token so the browser's auth cookie survived relaunches, and exposed palette +commands to stop and restart the background server. It required the desktop VS Code app to +ship a `code-tunnel` CLI binary; when that binary was missing, the feature was unavailable and +fell back invisibly (the command palette entry and menu item just didn't show/enable). + +## How it was wired + +- Menu: File > "Open Folder in VS Code (Inline)…" in `Sources/ProgramaApp.swift`. +- Command palette: `palette.openFolderInVSCodeInline` (open-folder panel), + `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, plus a per-target entry via + `TerminalDirectoryOpenTarget.vscodeInline` in the generic "open current directory in X" list. +- Settings: none (no toggle; availability was fully derived from whether `code-tunnel` existed + on disk). +- Localized keys: `menu.file.openFolderInVSCodeInline(.panelTitle/.panelPrompt)`, + `command.openFolderInVSCodeInline.(title/subtitle)`, `command.vscodeServeWeb(Stop/Restart).title`, + `menu.openInVSCode` (the inline variant; `menu.openInVSCodeDesktop` for the plain "Open in VS + Code" case was kept). +- `TerminalDirectoryOpenTarget.vscodeInline` case in `Sources/TerminalDirectoryOpener.swift` + drove availability detection (required `code-tunnel` to be executable, via + `VSCodeCLILaunchConfigurationBuilder`) and shared the desktop VS Code's app-bundle candidates. +- `AppDelegate.openDirectoryInInlineVSCode` / `showOpenFolderInInlineVSCodePanel` opened the + panel and routed the chosen directory into a new browser split. +- `AppDelegate.applicationWillTerminate` called `VSCodeServeWebController.shared.stop()` on quit. +- pbxproj: `VSCodeIntegration.swift` (Sources) and `programaTests/ServeWebPortStoreTests.swift` + (test Sources) build-phase/file-reference/group entries. +- No socket/CLI/MCP command existed for this feature. + +## Files removed and files edited + +Removed: +- `Sources/VSCodeIntegration.swift` (`VSCodeServeWebURLBuilder`, `VSCodeCLILaunchConfigurationBuilder`, + `VSCodeServeWebController`, `ServeWebOutputCollector`, `ServeWebPortStore`) +- `programaTests/ServeWebPortStoreTests.swift` + +Edited: +- `Sources/TerminalDirectoryOpener.swift`: dropped the `.vscodeInline` enum case and every + switch arm referencing it; `isAvailable()` no longer special-cases VS Code CLI-binary + detection. +- `Sources/ContentView.swift`: dropped the `palette.openFolderInVSCodeInline`, + `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart` command-palette contributions, + their registrations, and the `openFocusedDirectoryInInlineVSCode` / + `stopInlineVSCodeServeWeb` / `restartInlineVSCodeServeWeb` helpers. +- `Sources/AppDelegate.swift`: dropped `openDirectoryInInlineVSCode`, + `showOpenFolderInInlineVSCodePanel`, and the `VSCodeServeWebController.shared.stop()` call in + `applicationWillTerminate`. +- `Sources/ProgramaApp.swift`: dropped the "Open Folder in VS Code (Inline)…" menu button. +- `GhosttyTabs.xcodeproj/project.pbxproj`: dropped 8 entries for the two deleted files. +- `Resources/Localizable.xcstrings`: dropped 8 keys (see above). +- `programaTests/TerminalAndGhosttyTests.swift`: removed + `testVSCodeInlineRequiresCodeTunnelExecutable` and the `.vscodeInline` assertions inside + `testAvailableTargetsFallbackToApplicationLookupForVSCodeAliasOutsideApplications`. +- `programaTests/OmnibarAndToolsTests.swift`: removed `VSCodeServeWebURLBuilderTests`, + `VSCodeCLILaunchConfigurationBuilderTests`, `ServeWebOutputCollectorTests`, + `VSCodeServeWebControllerTests`. + +## What we learned + +CHANGELOG.md (0.2.x "Fixed" section) records two shipped bugs this subsystem carried: the +serve-web port and sign-in token did not originally persist across restarts (fixed — port +persisted via `ServeWebPortStore`, connection token persisted to Application Support, both +tagged `#21` in code comments), and the sign-in popup briefly showed `about:blank` before +loading. `docs/audits/codebase-audit-2026-08-31.md` finding M4 ("Bounded-input policy is +repeatedly applied after allocation") flagged `VSCodeIntegration.swift:289-307` as one of four +call sites that buffer input before enforcing a size limit — the code as removed did already +carry a `maximumBytes` cap on `ServeWebOutputCollector` (default 1 MiB) with overflow handling, +so this looks like a finding that was addressed after the audit ran; verify against the audit +diff if this is ever rebuilt rather than assuming the cap was always there. The controller used +a generation-counter pattern (`lifecycleGeneration`/`activeLaunchGeneration`) to make +stop-during-launch races safe — worth keeping if this is rebuilt, since serve-web startup was a +multi-second subprocess launch racing against user-triggered stop/restart. + +## Why removed and what a future version should do differently + +This was a heavyweight, single-purpose integration: a background subprocess, a custom URL +builder, connection-token file management, and a whole browser-embedding code path, all to +avoid alt-tabbing to the desktop VS Code window. It also silently degraded (no error UI) when +`code-tunnel` was missing, which is easy to ship broken without noticing. A future version +should default users to the desktop `.vscode` open target (kept) and only reconsider an inline +browser-embedded editor if there's a concrete user request, at which point it should reuse +Programa's existing browser-split and directory-open primitives rather than re-deriving them. diff --git a/docs/removed/mobile-bridge-and-ios.md b/docs/removed/mobile-bridge-and-ios.md new file mode 100644 index 00000000..6acb0d84 --- /dev/null +++ b/docs/removed/mobile-bridge-and-ios.md @@ -0,0 +1,193 @@ +# Mobile Bridge and iOS companion + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with: +`git checkout 903027ccef -- Sources/MobileBridge ios tools/mobile-spike vendor/CmuxIrohTransport vendor/CMUXMobileCore .github/workflows/ios-build.yml .github/workflows/ios-testflight.yml scripts/build-ios-testflight.sh docs/ios-testflight-setup.md programaTests/MobileBridgeConnectionRegistryTests.swift` + +The pbxproj entries, `Package.resolved` pin, and the edits listed below would +also need to be reapplied by hand; they are not a clean `git checkout`. + +## What it did + +Programa could pair with a companion iPhone app (`ProgramaSpike`) over a +private peer-to-peer connection built on Iroh, Apple's QUIC-based relay/hole +punching library. A Settings > Phone tab let the user turn on "Mobile +Companion" (off by default), start a single-use 5-minute pairing window, and +scan a QR code or paste a pairing code from the iPhone app. Paired phones +could see workspace/agent state and get "an agent needs you" push +notifications (via CloudKit) when a paired Mac and iPhone shared the same +iCloud account. A small allow-listed method surface (`MobileBridgeMethodAllowList`) +limited what a paired phone could ask the Mac to do, independent of the Unix +control socket's own auth. Devices could be revoked from the Paired Devices +list. + +## How it was wired + +- Settings key: `MobileBridgeSettings.appStorageKey`, default `.off` + (`MobileBridgeSettings.defaultMode`). +- Settings UI: a `.phone` case on `SettingsTab` (`Sources/SettingsModels.swift`) + and a `phoneSection` view in `Sources/SettingsView.swift` (mode picker, + pairing button, QR code render via CoreImage, paired-device list with + revoke). +- Start/stop hook: `ProgramaApp.swift` called `updateMobileBridgeController()` + on launch and on `mobileBridgeMode` change, which started/stopped + `MobileBridgeListener.shared`. `AppDelegate.applicationWillTerminate` also + stopped it. +- Telemetry push: `Workspace+SidebarTelemetry.swift` called + `MobileBridgePush.shared.noteAgentStateChanged(...)` on every agent state + change/clear/reset so a paired phone got near-real-time updates. +- Socket layer: `TerminalController.SocketConnectionSource` had a + `.mobileBridge` case with its own `mobileBridgeRequestPolicy()` (no password + auth, since the Mobile Bridge allow-list was the gate instead). +- iOS app: `ios/ProgramaSpike` (33 files), a SwiftUI/SwiftData app with + `BridgeConnection` (QUIC client), `PairingStore`/`SecretKeyStore` (identity), + `CloudKitPush` (push subscription), `PathClassifier` (relay vs. direct path + reporting), Live Activities. +- Vendored transport: `vendor/CmuxIrohTransport` and `vendor/CMUXMobileCore`, + one-time source copies from the upstream cmux fork (see their now-deleted + `PROVENANCE.md`), linked into the iOS project and `tools/mobile-spike` but, + per the audit, never actually imported by that code (only `IrohLib` was). +- SPM package: `iroh-ffi` (`MOBB1001` remote package reference, `MOBB1002` + `IrohLib` product, `MOBB1003` build file) was linked into the macOS + `programa` target for `MobileBridgeListener`'s QUIC listener. +- CI/build: `.github/workflows/ios-build.yml`, `.github/workflows/ios-testflight.yml`, + `scripts/build-ios-testflight.sh`, and `docs/ios-testflight-setup.md` + (TestFlight signing/shipping for the iOS app, gated on Apple secrets that + were never added to the repo — the lane always skipped). +- `scripts/sign-release-app.sh` re-signed a prebuilt `Iroh.framework` inside + the macOS app bundle (the xcframework carried its own upstream signature). +- Test: `programaTests/MobileBridgeConnectionRegistryTests.swift`, plus two + tests in `TerminalControllerSocketSecurityTests.swift` that exercised the + `.mobileBridge` socket source directly. +- 21 Localizable.xcstrings keys (`settings.phone.*`, `settings.section.phone`, + `settings.tab.phone`). + +## Files removed and files edited + +Removed: +- `Sources/MobileBridge/` (MobileBridgeSettings, MobileBridgeListener, MobileBridgePush, MobileBridgePairingCode, MobileBridgeSession, MobileBridgeStreamSupport) +- `ios/` (ProgramaSpike app, 33 files) +- `tools/mobile-spike/` +- `vendor/CmuxIrohTransport/`, `vendor/CMUXMobileCore/` +- `.github/workflows/ios-build.yml`, `.github/workflows/ios-testflight.yml` +- `scripts/build-ios-testflight.sh` +- `docs/ios-testflight-setup.md` +- `programaTests/MobileBridgeConnectionRegistryTests.swift` + +Edited: +- `GhosttyTabs.xcodeproj/project.pbxproj`: removed all `MOBB0001`-`MOBB0012` + build-file/file-reference entries, the `MOBB1001`/`MOBB1002`/`MOBB1003` + iroh-ffi package reference/product/build-file entries, and the + `MobileBridgeConnectionRegistryTests.swift` build-file/file-reference/group + entries. +- `GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`: + removed the `iroh-ffi` pin. +- `Sources/SettingsModels.swift`: removed the `.phone` case and its `title` + branch from `SettingsTab`. +- `Sources/SettingsView.swift`: removed the `mobileBridgeMode` app-storage + property, seven `@State` mobile-bridge-pairing properties, the `.phone` + tab-switch case, the `refreshMobileBridgePairedDevices()` `onAppear` call, + and the whole `phoneSection` view plus its seven private helper + functions/computed properties. +- `Sources/ProgramaApp.swift`: removed the `mobileBridgeMode` app-storage + property, the `updateMobileBridgeController()` call on appear and its + `onChange(of: mobileBridgeMode)` handler, the function itself, and + generalized the `SIGPIPE` ignore comment (the ignore itself stays — other + sockets need it too). +- `Sources/AppDelegate.swift`: removed the `MobileBridgeListener.shared.stop()` + call from `applicationWillTerminate`. +- `Sources/Workspace+SidebarTelemetry.swift`: removed the three + `MobileBridgePush.shared.noteAgentStateChanged(...)` calls. +- `Sources/TerminalController.swift`: removed the `.mobileBridge` case from + `SocketConnectionSource`, the `mobileBridgeRequestPolicy()` function, and + the corresponding switch arm in the connection handler. +- `programaTests/TerminalControllerSocketSecurityTests.swift`: removed the two + Mobile Bridge socket-policy tests and their `sendPingThroughMobileBridgeHandler` + helper; the rest of the file (Unix socket auth/rotation tests) is untouched. +- `scripts/sign-release-app.sh`: removed the `Iroh.framework` re-sign step and + its comment (the framework is no longer embedded). +- `Resources/Localizable.xcstrings`: removed 21 `settings.phone.*` / + `settings.section.phone` / `settings.tab.phone` keys. + +## What we learned + +From `CHANGELOG.md` (Unreleased): the mobile bridge shipped with real +security work already landed — device-only Keychain credentials, bounded +newline framing, cancellation-safe request/pairing deadlines, settled path +selection, server-reconciled CloudKit subscriptions, a generation-owned +reconnect loop, and revoking a device also closed its active session. That +work is gone with the feature; a reimplementation should not start from +scratch on those points, it should re-port them. + +From the 2026-08-31 audit (`docs/audits/codebase-audit-2026-08-31.md`): + +- **J3**: the repo simultaneously called the iOS app "a spike," auto-shipped + it to TestFlight, linked an unused transport package (`CmuxIrohTransport`), + and kept a second executable spike (`tools/mobile-spike`) as reference code. + The audit's direction was to promote one implementation or drop + auto-shipping — this removal takes the "drop it" branch instead of + resolving the ambiguity. +- **H8**: `BridgeConnection.withRequestTimeout` raced the operation against a + timeout inside a structured task group, but cancelling the child neither + removed nor resumed its `pending` continuation, and teardown only ran after + the timeout helper returned — a silent, authenticated peer could leave the + app on "Connecting" indefinitely. +- **M1**: `BridgeConnection.nextBufferedLine` (iOS) appended unbounded 64 KiB + chunks and rescanned from the start every time — O(n^2) and unbounded + memory for a peer that never sends `\n`. The Mac-side + `MobileBridgeStreamSupport` had an 8 MiB cap and incremental cursor that the + iOS side never got ported to. +- **M2**: the shipped mobile identity was explicitly labeled "spike-grade" in + its own source comments — `SecretKeyStore` kept the Iroh private key in + UserDefaults, and `PairingStore` did the same for the pairing ticket, + instead of Keychain with a device-only accessibility class. +- **M8**: iOS reported the first non-unavailable network path as final instead + of waiting for Iroh's relay-first settlement to resolve to a direct/private + path, so users could see "connected" over a slow relay hop that would soon + upgrade. +- **M10**: `build-ios-testflight.sh` replaced the entire keychain search list + with a fixed `ios-build.keychain` and had no EXIT trap, so a local + (non-CI) run could leave a developer's normal keychains undiscoverable. +- **M11**: both the iOS project and `tools/mobile-spike` declared a link + dependency on `CmuxIrohTransport` but their source only imported `IrohLib` + directly — the vendored package was dead weight, and the hand-copied + implementations had already drifted from it on path settlement and line + bounds. + +`vendor/CmuxIrohTransport/PROVENANCE.md` and `vendor/CMUXMobileCore/PROVENANCE.md` +recorded these as one-time source copies from the upstream cmux fork +(`Packages/Shared/CmuxIrohTransport` and `Packages/Shared/CMUXMobileCore` at +commit `34cc2ba5110adf45c27607e865be5867fbcad8a9`), with no live tracking +after extraction, citing a 4,706-commit divergence from upstream and a risk +that upstream commits would reintroduce account/broker coupling this fork +deliberately removed. `tools/mobile-spike/README.md` said the completed +executable spike and its direct `iroh-ffi` dependency had already been +removed once before, with the framer kept only as reference — the production +transport had moved to `ios/ProgramaSpike`. + +`docs/ios-testflight-setup.md` (read before deletion) documented that the +TestFlight CI lane was fully written and verified but permanently skipping +every run because none of the seven required Apple signing secrets were ever +added to the repo. Setup required careful ordering (iCloud container and Push +capability before minting provisioning profiles, since a profile bakes in +capabilities at creation time) and named three App IDs that must never be +deleted if the feature returns: `com.darkroom.programa`, +`com.darkroom.programa.spike`, `com.darkroom.programa.spike.widgets`. + +## Why removed and what a future version should do differently + +The feature was off by default, had zero consumers in the terminal/workspace/ +browser core, and its own audit trail (J3, H8, M1, M2, M8, M10, M11) shows it +never graduated past spike quality on security or reliability despite +auto-shipping to TestFlight. Keeping an unfinished second client surface (iOS) +plus a vendored, partially-unused transport dependency (Iroh/CmuxIrohTransport) +added real build and audit surface for a feature nobody outside the audit was +using. + +A future version should pick one implementation up front instead of running +spike and "production" copies in parallel: port `MobileBridgeStreamSupport`'s +bounded reader and the Mac-side pairing/registry logic into the iOS client +rather than re-deriving it, put the Iroh identity and pairing ticket in +Keychain from the start, fix request-timeout cancellation before any pairing +flow ships, and treat TestFlight auto-shipping as a deliberate go-live +decision — not a default CI lane that silently skips for months because +secrets were never added. diff --git a/docs/removed/ssh-remote-workspaces.md b/docs/removed/ssh-remote-workspaces.md new file mode 100644 index 00000000..9d391cd2 --- /dev/null +++ b/docs/removed/ssh-remote-workspaces.md @@ -0,0 +1,63 @@ +# SSH remote workspaces + +Removed 2026-09-02. Last present at commit 903027ccef. Restore with `git checkout 903027ccef -- Sources/Workspace+Remote.swift Sources/WorkspaceRemoteCLIRelayServer.swift Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift Sources/WorkspaceRemoteDaemonRPCClient.swift Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift Sources/WorkspaceRemoteModels.swift Sources/WorkspaceRemoteProxyBroker.swift Sources/WorkspaceRemoteSession.swift Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift Sources/WorkspaceRemoteSessionController+DaemonInstall.swift Sources/WorkspaceRemoteSessionController+PortScanning.swift Sources/WorkspaceRemoteSessionController+ProcessExecution.swift Sources/WorkspaceRemoteSessionController+ScriptBuilders.swift Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift Sources/RemoteRelayZshBootstrap.swift Sources/RemoteSCPUpload.swift Sources/RemoteSSHConnectionPolicy.swift Sources/SidebarRemoteErrorCopy.swift Sources/TerminalSSHSessionDetector.swift CLI/CLI+SSH.swift daemon docs/remote-daemon-spec.md scripts/build_remote_daemon_release_assets.sh programaTests/WorkspaceRemoteConnectionTests.swift tests/test_remote_daemon_release_assets.sh tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py tests_v2/test_ssh_remote_cli_metadata.py tests_v2/test_ssh_remote_cli_relay.py tests_v2/test_ssh_remote_daemon_resize_stdio.py tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py tests_v2/test_ssh_remote_docker_forwarding.py tests_v2/test_ssh_remote_docker_reconnect.py tests_v2/test_ssh_remote_interactive_cmux_command_regression.py tests_v2/test_ssh_remote_last_surface_clears_remote_state.py tests_v2/test_ssh_remote_port_detection.py tests_v2/test_ssh_remote_proxy_bind_conflict.py tests_v2/test_ssh_remote_resize_scrollback_regression.py tests_v2/test_ssh_remote_second_session_mux_regression.py tests_v2/test_ssh_remote_shell_integration.py tests_v2/test_ssh_remote_shortcuts_stay_remote.py`. + +## What it did + +`programa ssh user@host` created a workspace whose terminals ran on a remote machine over SSH. The workspace looked and behaved like a local one: the sidebar showed its host and connection state, splits inherited the SSH startup command so every new pane landed on the same host, and closing the last remote terminal demoted the workspace back to a local one. A Go daemon named `programad-remote` was uploaded to the remote host on first connect and spawned per SSH connection. Browser panes inside a remote workspace routed all their HTTP and WebSocket traffic through that daemon, so a dev server bound to `localhost:3000` on the remote host opened in a Programa browser pane exactly as it would on the remote machine's own desktop. The daemon also published a reverse-forwarded relay port so `programa` commands typed inside a remote SSH terminal reached the local app's socket API. The sidebar context menu offered Reconnect and Disconnect for remote workspaces, and connection failures surfaced as a copyable sidebar error. Images dropped or pasted onto a remote terminal were uploaded over SCP and the remote path was inserted instead of the local one. + +## How it was wired + +Entry points: the `programa ssh` CLI subcommand (plus the internal `programa ssh-session-end` and `programa remote-daemon-status` commands); the sidebar context-menu items "Reconnect Workspace(s)" and "Disconnect Workspace(s)"; and the socket commands `workspace.remote.configure`, `workspace.remote.reconnect`, `workspace.remote.disconnect`, `workspace.remote.status`, `workspace.remote.foreground_auth_ready`, and `workspace.remote.terminal_session_end`. No MCP tool was ever exposed; `CLI-MCP/ToolCatalog.swift` listed `workspace.remote.*` as deliberately out of scope. There was no keyboard shortcut and no user-facing settings key. `workspace.list` payloads carried a `remote` object; the CLI printed `[ssh:<state>]` next to remote workspaces in `list-workspaces`. The app read a `PROGRAMARemoteDaemonManifestJSON` Info.plist key (written into the plist at release time as `CMUXRemoteDaemonManifestJSON`) holding pinned asset URLs and SHA-256 digests for the four daemon binaries. Release assets `programad-remote-{darwin,linux}-{arm64,amd64}`, a checksums file, and a manifest JSON were built by `scripts/build_remote_daemon_release_assets.sh` and published by `.github/workflows/release.yml`; the release payload contract was therefore ten assets rather than four. CI had a `remote-daemon-tests` job gated by a `run_remote_daemon_jobs` output from `scripts/classify_ci_changes.sh` and a `daemon/**` path filter. + +## Files removed and files edited + +Deleted: `Sources/Workspace+Remote.swift`, `Sources/WorkspaceRemoteCLIRelayServer.swift`, `Sources/WorkspaceRemoteDaemonPendingCallRegistry.swift`, `Sources/WorkspaceRemoteDaemonRPCClient.swift`, `Sources/WorkspaceRemoteLoopbackHTTPRewriting.swift`, `Sources/WorkspaceRemoteModels.swift`, `Sources/WorkspaceRemoteProxyBroker.swift`, `Sources/WorkspaceRemoteSession.swift`, `Sources/WorkspaceRemoteSessionController+ConnectionOrchestration.swift`, `+DaemonInstall.swift`, `+PortScanning.swift`, `+ProcessExecution.swift`, `+ScriptBuilders.swift`, `Sources/WorkspaceRemoteSSHBatchCommandBuilder.swift`, `Sources/RemoteRelayZshBootstrap.swift`, `Sources/RemoteSCPUpload.swift`, `Sources/RemoteSSHConnectionPolicy.swift`, `Sources/SidebarRemoteErrorCopy.swift`, `Sources/TerminalSSHSessionDetector.swift`, `CLI/CLI+SSH.swift`, all of `daemon/`, `docs/remote-daemon-spec.md`, `scripts/build_remote_daemon_release_assets.sh`, `programaTests/WorkspaceRemoteConnectionTests.swift`, `tests/test_remote_daemon_release_assets.sh`, and the sixteen `tests_v2/test_ssh_remote_*.py` suites. + +Edited: +- `Sources/Workspace.swift` — dropped every remote published property, the SSH control-master cleanup queue, the remote sidebar publishers, the remote fields on `DetachedSurfaceTransfer`, and the remote branches in detach/attach. +- `Sources/Workspace+Persistence.swift` — dropped the daemon-manifest accessors and the rule that zeroed a remote surface's listening ports. Snapshot decoding is unchanged, so old session files that still carry remote fields still restore. +- `Sources/Workspace+Surfaces.swift` — split and surface creation no longer inject the remote startup command or the workspace-scoped browser data store. +- `Sources/Workspace+Bonsplit.swift`, `Sources/Workspace+SidebarTelemetry.swift` — removed remote surface tracking, the remote TTY sync, and remote ports from the listening-port aggregate. +- `Sources/WorkspaceSidebarModels.swift` — removed `inferredRemoteHomeDirectory` and the two private helpers only it used. +- `Sources/TabManager.swift`, `+SessionPersistence.swift`, `+GitMetadataPolling.swift` — removed remote teardown, the live-remote snapshot exclusion, and the remote git-probe skip. +- `Sources/TerminalController.swift`, `+Workspace.swift`, `+Telemetry.swift` — removed the six `workspace.remote.*` handlers, the `remote` key in workspace payloads, and the remote branches in `surface.report_tty` / `surface.ports_kick`. `surface.ports_kick` still validates and canonicalizes its `reason` argument. +- `Sources/V2CommandCatalog.swift` — removed the six command names. +- `Sources/VerticalTabsSidebar.swift`, `Sources/TabItemView.swift` — removed the remote row section, the reconnect/disconnect menu items, the copyable SSH error, and the three remote properties from the `TabItemView` Equatable contract. +- `Sources/Panels/BrowserPanel.swift`, `+Navigation.swift`, `Sources/Panels/BrowserSettings.swift` — removed proxy-endpoint wiring, loopback alias rewriting, the pending-navigation queue, and `BrowserRemoteWorkspaceStatus`. The `browser.proxy` user setting still applies, now through `applyUserProxyConfiguration`. +- `Sources/TerminalImageTransfer.swift`, `Sources/GhosttyTerminalView+DragDrop.swift`, `Sources/GhosttyNSView.swift`, `Sources/GhosttyApp.swift`, `Sources/GhosttyTerminalView+Mouse.swift` — the drop/paste planner now only inserts escaped local paths; the upload plan and its two upload callbacks are gone. +- `Sources/SessionEscrow.swift` — removed the daemon reference in a doc comment. +- `CLI/programa.swift`, `CLI/CLICommandDispatcher.swift` — removed the `ssh`, `ssh-session-end`, and `remote-daemon-status` commands and the TCP relay transport (endpoint parsing, `programa-relay-auth` HMAC handshake, line reader). +- `CLI-MCP/ToolCatalog.swift`, `CLI-MCP/MCPSocketBridge.swift` — removed the exclusion notes. +- `Resources/Localizable.xcstrings` — removed 21 orphaned keys; `Resources/settings.schema.json` — removed the relay-precedence sentence from `browser.proxy`. +- `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `scripts/classify_ci_changes.sh`, `scripts/release_asset_guard.js`, `scripts/milestone_payload.js`, `scripts/rolling_release_state.js`, `scripts/publish_rolling_release.sh`, `scripts/publish_milestone_release.sh`, `scripts/publish_release_candidate.sh`, `scripts/restore_release_candidate.sh` — the release payload is now four assets (DMG, dSYMs, appcast, stable DMG alias) instead of ten, and the Go setup plus daemon build/upload steps are gone. +- Tests edited rather than deleted: `programaTests/GhosttyConfigTests.swift`, `TabManagerUnitTests.swift`, `SidebarOrderingTests.swift`, `TerminalAndGhosttyTests.swift`, `TabManagerSessionSnapshotTests.swift`, `TerminalControllerSocketSecurityTests.swift`, `SessionPersistenceTests.swift`, `MCPSocketBridgeTests.swift`, `WorkspaceUnitTests.swift`, `scripts/*.test.js`, `tests/test_milestone_release_publication.sh`, `tests/test_rolling_release_publication.sh`, `tests/test_ci_change_classification.sh`. +- Docs: `README.md`, `docs/mcp-server.md`, `docs/agent-skill.md`, `CLAUDE.md` (and its `AGENTS.md` symlink). + +## What we learned + +The living spec (`docs/remote-daemon-spec.md`) records the design decisions and the reasons behind them. + +Port mirroring was tried first and abandoned. The original design probed the remote host for listening ports and mirrored each one to local loopback with `ssh -L`. That was replaced by a single shared local proxy endpoint per SSH transport, brokered over the daemon's stdio RPC (`proxy.open/close/write/proxy.stream.subscribe` plus pushed `proxy.stream.*` events). Section 4.4 states the conclusion plainly: automatic mirroring of every remote listening port is an explicit non-goal. A re-implementation should start from one proxy endpoint per transport, not per port. + +Reverse forwarding had to use TCP, not a Unix socket. The CLI relay used `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_PORT` because many servers disable `AllowStreamLocalForwarding`. The relay process also had to run with `-S none` so it did not inherit ControlMaster multiplexing or the user's `RemoteForward` directives, and with `ExitOnForwardFailure=yes` so a dead reverse bind failed fast instead of publishing a relay address nothing was listening on. The relay address was written to `~/.programa/socket_addr` only after the forward survived startup validation. + +Parallel sessions raced on shared state. Multiple `programa ssh` sessions to the same host overwrote each other's `~/.programa/socket_addr`, so each session had to export a session-local `PROGRAMA_SOCKET_PATH=127.0.0.1:<relay_port>`. Per-relay files (`<port>.auth`, `<port>.daemon_path`) let mixed local Programa versions coexist on one remote host. Ephemeral ports (49152-65535) were filtered out of probe results so one workspace's relay port never showed up as another's detected service, and port-conflict detection used a TCP connect probe so a port already forwarded by a sibling workspace was skipped silently rather than reported as a conflict. + +Three separate socket clients drifted apart. Audit finding H2 (2026-08-31) is the clearest lesson: the Go CLI relay, the Swift CLI, the relay server, and the MCP bridge each hand-implemented the protocol, and they disagreed. Six Go commands still selected the removed v1 text protocol and `execV1` printed the `v1_removed` error while exiting 0, so automation saw success for commands that never ran. The Swift CLI required a `cmux-relay-auth` identifier while the relay server and Go client used `programa-relay-auth`, meaning the Swift remote endpoint could not authenticate at all. The audit's J1 direction is the right one: define methods, parameter names, auth identifiers, and error codes once, then generate thin bindings. + +Two security findings were confirmed and are worth carrying forward. H3: the daemon put a Node bootstrap module in a predictable `/tmp` path without checking directory ownership, mode, or symlinks, so another user on a shared remote host could replace it between the atomic rename and Node startup and run code in the victim's Claude process. Put per-user runtime files in an owner-verified `0700` directory under `$HOME`. H4: daemon downloads read `URLSession`'s callback-owned temporary URL after the completion handler returned, so a successful download could fail as "file not found"; both waits also ignored the semaphore timeout result, letting a timed-out callback mutate state the caller had already moved past. Move the file inside the handler and return an immutable result through one synchronization primitive. M16 (plausible, not confirmed) noted that relay stderr readability handlers could append to a buffer after teardown and contaminate the next relay's diagnostics; a generation token per process would fix it. + +The daemon shipped supply-chain machinery that mostly worked. Release apps embedded a manifest with exact asset URLs and SHA-256 digests; the app verified the digest before running a downloaded binary; `programa remote-daemon-status` exposed the manifest entry, cache verification state, and the GitHub attestation command. Version-scoped installs under `$HOME/.programa/bin/programad-remote/` were pruned on every fresh install, retaining the current version plus the most-recently-used other version, decided by directory mtime rather than version-string comparison (audit M12). H10 flagged that `daemon/remote/go.mod` still declared Go 1.22, outside Go's two-release support window, for code parsing attacker-controlled remote input; the toolchain was later moved to Go 1.26.7 in CI. + +One advertised feature never actually shipped. The daemon implemented tmux-style "smallest screen wins" PTY resize (`session.open/attach/resize/detach/status/close`, min cols and rows across attachments, last-known size preserved when nothing is attached) with unit tests and an stdio round-trip test, but the app never called `session.*`. Real `programa ssh` terminals did not get that behavior; the spec marks those five scenarios DAEMON-ONLY (audit 2026-08-20, M10). + +Two smaller gotchas: the SOCKS5 handshake parser originally dropped pipelined payload bytes that arrived in the same write as the greeting and connect request, which broke fast clients; and concurrent proxy connections needed isolated serial executors so one stalled stream could not block another. Both were fixed and both are easy to reintroduce. + +Session persistence carried a scar worth remembering. Snapshots excluded only *live* remote workspaces, never merely-configured ones, because `isRemoteWorkspace` stays true after a user-initiated disconnect. An earlier version keyed on the wrong flag and three live local terminals vanished on relaunch. + +## Why removed and what a future version should do differently + +This is a reductive pass toward a lean, fast local terminal. Roughly 17,000 lines of Swift plus an 8,600-line Go daemon served one workflow, and it cost far more than its own footprint: a second language and toolchain in CI, six extra release assets on every ship, an Info.plist manifest, a supply-chain verification path, remote branches threaded through workspace creation, persistence, sidebar telemetry, browser panes, and image drag-and-drop, and four hand-maintained socket clients that drifted into mutual incompatibility. + +A future version should not rebuild this shape. Make the JSON-RPC contract the product boundary and generate every client from it, so a remote client cannot disagree with the local one. Keep the remote surface behind one narrow interface instead of branching the whole workspace model; the removal was tractable only because most call sites were `if isRemote` branches, and it would have been trivial if they had been one seam. Ship the resize coordinator wired to the app or do not ship it at all. Treat any per-user file on a shared remote host as attacker-adjacent from the first commit. And if browser traffic must egress from a remote network again, keep the single-endpoint-per-transport design that this implementation converged on the hard way. diff --git a/docs/settings-json.md b/docs/settings-json.md new file mode 100644 index 00000000..6060596e --- /dev/null +++ b/docs/settings-json.md @@ -0,0 +1,113 @@ +# settings.json + +Programa reads `~/.config/programa/settings.json` on launch and reloads it whenever the file changes, so edits apply without a restart. Every key here also has a control in the Settings window; a key set in the file wins over the Settings window until you remove it from the file. The file may contain `//` comments. The full contract is `Resources/settings.schema.json` in the repository, and this page is generated from it. + +Keys you do not set fall back to the defaults listed below. Keyboard shortcuts live under `shortcuts.bindings` and are documented in [keyboard-shortcuts.md](keyboard-shortcuts.md); terminal theme and font keys are explained in [terminal-themes.md](terminal-themes.md). + +## `app` + +General app preferences from Settings > App. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `appearance` | string | `"system"` | App appearance mode. One of: `system`, `light`, `dark`. | +| `terminalTheme` | object | `null` | Terminal themes from Settings > Appearance. Use null, or null for both variants, to remove Programa's managed theme override and inherit Ghostty configuration. | +| `terminalOpacity` | object | `null` | Terminal background opacity from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | +| `terminalBlur` | object | `null` | Terminal background blur from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | +| `terminalFont` | object | `null` | Terminal font from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration. | +| `newWorkspacePlacement` | string | `"afterCurrent"` | Where new workspaces are inserted in the sidebar. One of: `top`, `afterCurrent`, `end`. | +| `minimalMode` | boolean | `false` | Hide the workspace title bar and move controls into the sidebar. | +| `preferredEditor` | string | | Custom editor command used by Programa where applicable. Leave empty to use the default. | +| `reorderOnNotification` | boolean | `true` | Move workspaces with new notifications toward the top. | +| `warnBeforeQuit` | boolean | `true` | Show a confirmation before quitting Programa. | +| `commandPaletteSearchesAllSurfaces` | boolean | `false` | Search every surface in the command palette switcher instead of only the active workspace. | + +## `notifications` + +Notification behavior from Settings > Notifications. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `showInMenuBar` | boolean | `false` | Show the menu bar extra. | +| `sound` | string | `"default"` | Notification sound preset. One of: `default`, `Basso`, `Blow`, `Bottle`, `Frog`, `Funk`, `Glass`, `Hero`, `Morse`, `Ping`, `Pop`, `Purr`, `Sosumi`, `Submarine`, `Tink`, `none`. | +| `command` | string | | Optional shell command to run alongside notification delivery. | +| `longCommandThresholdSeconds` | integer | `30` | Minimum duration, in seconds, a command must run before a finish notification is posted for a pane the user isn't looking at. 0 disables this notification entirely. | + +## `workspaceColors` + +Workspace tab and badge colors from Settings > Workspace Colors. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `indicatorStyle` | string | `"leftRail"` | Active workspace indicator style. Legacy aliases are accepted and normalized. One of: `leftRail`, `solidFill`, `rail`, `border`, `wash`, `lift`, `typography`, `washRail`, `blueWashColorRail`. | +| `selectionColor` | object | `null` | Override the selected workspace background color. | +| `notificationBadgeColor` | object | `null` | Override the unread notification badge color. | +| `colors` | object | `{"Red": "#C0392B", "Crimson": "#922B21", "Orange": "#A04000", "Amber": "#7D6608", "Olive": "#4A5C18", "Green": "#196F3D", "Teal": "#006B6B", "Aqua": "#0E6B8C", "Blue": "#1565C0", "Navy": "#1A5276", "Indigo": "#283593", "Purple": "#6A1B9A", "Magenta": "#AD1457", "Rose": "#880E4F", "Brown": "#7B3F00", "Charcoal": "#3E4B5E"}` | Full named workspace color palette. Include built-in entries you want to keep, remove keys to remove colors, and add more named entries to extend the picker. | + +## `sidebarAppearance` + +Sidebar tint settings from Settings > Sidebar Appearance. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `matchTerminalBackground` | boolean | `false` | Use the terminal background instead of the sidebar tint. | +| `tintColor` | object | `"#000000"` | Base sidebar tint color used when light/dark overrides are not set. | +| `lightModeTintColor` | object | `null` | Sidebar tint override for light appearance. | +| `darkModeTintColor` | object | `null` | Sidebar tint override for dark appearance. | +| `tintOpacity` | number | `0.03` | Sidebar tint opacity from 0 to 1. | +| `showClaudeQuota` | boolean | `true` | Show Claude Code rate-limit headroom (5h/7d windows) in the sidebar footer, read from ~/.claude/tmp/rate-limits.json when present. | + +## `automation` + +Socket control and automation settings from Settings > Automation. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `socketControlMode` | string | `"cmuxOnly"` | Socket control mode. Legacy aliases are accepted and normalized. One of: `off`, `cmuxOnly`, `automation`, `password`, `allowAll`, `openAccess`, `fullOpenAccess`, `notifications`, `full`. | +| `socketPassword` | object | | Password for password-mode socket access. Use null or an empty string to clear it. | +| `claudeCodeIntegration` | boolean | `true` | Enable Programa integration hooks for Claude Code. | +| `openBrowserWithAgentSplits` | boolean | `false` | Open a browser split beside the terminal when a new agent workspace is created. | +| `claudeBinaryPath` | string | | Custom path to the claude binary. | +| `portBase` | integer | `9100` | Starting value for workspace PROGRAMA_PORT assignments. The complete range must fit within 1-65535. | +| `portRange` | integer | `10` | Number of ports reserved per workspace. The complete range must fit within 1-65535. | + +## `customCommands` + +Custom command trust settings from Settings > Custom Commands. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `trustedDirectories` | array | `[]` | Directories whose programa.json (or legacy cmux.json) commands can run without confirmation. | + +## `browser` + +Embedded browser settings from Settings > Browser. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `defaultSearchEngine` | string | `"google"` | Default search engine for non-URL queries. One of: `google`, `duckduckgo`, `bing`, `kagi`, `startpage`. | +| `showSearchSuggestions` | boolean | `true` | Show omnibar search suggestions. | +| `theme` | string | `"system"` | Embedded browser theme. One of: `system`, `light`, `dark`. | +| `openTerminalLinksInProgramaBrowser` | boolean | `true` | Open clicked terminal links in the embedded browser. | +| `interceptTerminalOpenCommandInProgramaBrowser` | boolean | `true` | Intercept terminal open http(s) commands and route them through the embedded browser. | +| `hostsToOpenInEmbeddedBrowser` | array | `[]` | Allowlist of hosts that should stay inside the embedded browser. | +| `urlsToAlwaysOpenExternally` | array | `[]` | Rules that always open matching URLs in the system browser. | +| `insecureHttpHostsAllowedInEmbeddedBrowser` | array | `["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"]` | HTTP hosts allowed in the embedded browser without a warning prompt. | +| `proxy` | object | | Route the embedded browser through a proxy. Requires host and port; type defaults to socks5. | + +## `worktrees` + +Native git worktree workflow settings. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `directory` | string | `"~/.programa/worktrees"` | Base directory for new git worktrees created via 'programa worktree create' (or the 'worktree.create' socket method) when no explicit --path is given. Worktrees are created under <directory>/<repo-name>/<branch-slug>. | + +## `shortcuts` + +Keyboard shortcut settings from Settings > Keyboard Shortcuts. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `showModifierHoldHints` | boolean | `true` | Show shortcut hint pills while holding Cmd or Ctrl. | +| `bindings` | object | `{}` | Shortcut overrides keyed by Programa action id. Use a string for a single shortcut or an array for a chord. | diff --git a/docs/v2-api-migration.md b/docs/v2-api-migration.md index 65ea2184..44b49c3b 100644 --- a/docs/v2-api-migration.md +++ b/docs/v2-api-migration.md @@ -571,12 +571,11 @@ to guess: the `app.browsers` socket command, and two env vars set on every spawn `{}` -> `{"default": "chrome", "browsers": [{"key": "chrome", "name": "Google Chrome", "bundle_id": "com.google.Chrome", "path": "/Applications/Google Chrome.app", "installed": true, "running": false}, ...]}`. Read-only; no arguments. `browsers` covers the browsers `BrowserAvailability.knownBrowsers` -tracks in `Sources/Panels/BrowserDataImport.swift` (Safari, Chrome, Firefox, Arc, Brave, Edge, +tracks in `Sources/Panels/BrowserAvailability.swift` (Safari, Chrome, Firefox, Arc, Brave, Edge, Zen, Vivaldi, Opera, Opera GX, Orion, Dia, Perplexity Comet, Floorp, Waterfox, SigmaOS, Sidekick, Helium, Atlas, Ladybird, Chromium, Ungoogled Chromium, Aside). `installed` is true only when the app itself resolves (by bundle identifier via `NSWorkspace`, falling back to an -`/Applications`-style path scan) -- separate from, and does not affect, the leftover-profile-data -detection the browser data-import wizard uses to offer cookie/history import. `running` matches +`/Applications`-style path scan). `running` matches against `NSWorkspace.shared.runningApplications`. `default` is the short key of the system default browser (`nil` if Launch Services can't resolve one), same resolution as `PROGRAMA_DEFAULT_BROWSER` below. diff --git a/ios/ProgramaSpike/.gitignore b/ios/ProgramaSpike/.gitignore deleted file mode 100644 index b3086d20..00000000 --- a/ios/ProgramaSpike/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -ProgramaSpike.xcodeproj/ -# Generated by `xcodegen generate` from project.yml's `entitlements.properties` -- -# never hand-edit; the source of truth is project.yml. -ProgramaSpike/ProgramaSpike.entitlements -# Likewise generated, from project.yml's `info.properties`. Every Info.plist key -# lives there rather than in INFOPLIST_KEY_* build settings -- see the comment in -# project.yml for why mixing the two styles silently drops keys. -ProgramaSpike/Info.plist -# The widget's plist is generated the same way and was tracked by accident. A -# committed copy of a generated file drifts from what the build actually -# produces, which is how the hardcoded CFBundleVersion went unnoticed. -ProgramaSpikeWidgets/Info.plist diff --git a/ios/ProgramaSpike/ProgramaSpike/AgentDetailView.swift b/ios/ProgramaSpike/ProgramaSpike/AgentDetailView.swift deleted file mode 100644 index 16dc8c54..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/AgentDetailView.swift +++ /dev/null @@ -1,103 +0,0 @@ -import SwiftUI - -/// Screen 3 — the "unblock it" screen: per-surface state for one workspace, -/// plus a prompt field that issues `agent.prompt` to the chosen surface. -struct AgentDetailView: View { - @Bindable var store: AppStore - let workspaceID: String - - @State private var promptText: String = "" - @State private var selectedSurfaceID: String? - @State private var sendResultDescription: String? - @State private var isSending = false - - var body: some View { - List { - Section(String(localized: "agentDetail.section.surfaces", defaultValue: "Surfaces")) { - if store.surfaces(for: workspaceID).isEmpty { - Text(String(localized: "agentDetail.surfaces.empty", defaultValue: "No surfaces in this workspace.")) - .foregroundStyle(.secondary) - } - ForEach(store.surfaces(for: workspaceID)) { surface in - Button { - selectedSurfaceID = surface.id - } label: { - SurfaceRowView(surface: surface, isSelected: surface.id == selectedSurfaceID) - } - .buttonStyle(.plain) - } - } - - Section(String(localized: "agentDetail.section.unblock", defaultValue: "Unblock it")) { - TextField(String(localized: "agentDetail.prompt.placeholder", defaultValue: "Prompt text"), text: $promptText, axis: .vertical) - .lineLimit(2 ... 6) - Button(String(localized: "agentDetail.send.button", defaultValue: "Send")) { - Task { await send() } - } - .disabled( - selectedSurfaceID == nil - || promptText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || isSending - ) - if let sendResultDescription { - Text(sendResultDescription) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - .navigationTitle(store.workspaceTitle(for: workspaceID)) - .onAppear { - if selectedSurfaceID == nil { - selectedSurfaceID = store.surfaces(for: workspaceID).first?.id - } - } - } - - private func send() async { - guard let surfaceID = selectedSurfaceID else { return } - isSending = true - sendResultDescription = nil - let textToSend = promptText - do { - try await store.sendPrompt(surfaceID: surfaceID, text: textToSend) - sendResultDescription = String(localized: "agentDetail.send.success", defaultValue: "Sent.") - promptText = "" - } catch { - sendResultDescription = String.localizedStringWithFormat( - String(localized: "agentDetail.send.failed", defaultValue: "Failed: %@"), - "\(error)" - ) - } - isSending = false - } -} - -private struct SurfaceRowView: View { - let surface: SurfaceRow - let isSelected: Bool - - var body: some View { - HStack { - Image(systemName: surface.badge.symbolName) - .foregroundStyle(surface.badge.tint) - .frame(width: 24) - VStack(alignment: .leading) { - Text(surface.title) - Text(surface.badge.label) - .font(.caption) - .foregroundStyle(surface.badge.tint) - } - Spacer() - if isSelected { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.blue) - } - } - .contentShape(Rectangle()) - } -} - -#Preview { - AgentDetailView(store: AppStore(), workspaceID: "preview") -} diff --git a/ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift b/ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift deleted file mode 100644 index d387c2b2..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/AppDelegate.swift +++ /dev/null @@ -1,62 +0,0 @@ -import CloudKit -import UIKit -import UserNotifications - -/// M3: the parts of CloudKit push delivery that must work even before any SwiftUI scene -/// exists -- see `LiveActivityCloudKitBridge`'s doc comment for why the background-wake path -/// cannot depend on `AppStore`. -final class AppDelegate: NSObject, UIApplicationDelegate { - func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - // Required for the OS to hold an APNs token at all -- CloudKit registers that token - // with its own servers internally; Programa never sees or stores it directly. - application.registerForRemoteNotifications() - - // Only needed so the subscription's generic `alertBody` can actually show a lock-screen - // line. The `shouldSendContentAvailable` background wake works regardless of this - // authorization -- it is a separate iOS mechanism gated only by `UIBackgroundModes`. - UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _, error in - if let error { - NSLog("AppDelegate: notification authorization request failed: %@", "\(error)") - } - } - - return true - } - - func application( - _ application: UIApplication, - didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data - ) { - // No-op by design: CloudKit owns device-token registration with its own servers. - // Programa never persists or transmits this token. - } - - func application( - _ application: UIApplication, - didFailToRegisterForRemoteNotificationsWithError error: Error - ) { - NSLog("AppDelegate: remote notification registration failed: %@", "\(error)") - } - - /// The ~30-second background-wake budget Apple grants for a `content-available` push. - /// `LiveActivityCloudKitBridge.reconcile()` is one CloudKit record fetch plus (at most) one - /// local `Activity.update()` -- comfortably inside that window. - func application( - _ application: UIApplication, - didReceiveRemoteNotification userInfo: [AnyHashable: Any], - fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void - ) { - guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else { - completionHandler(.noData) - return - } - - Task { - let result = await LiveActivityCloudKitBridge.reconcile() - completionHandler(result) - } - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/AppStore.swift b/ios/ProgramaSpike/ProgramaSpike/AppStore.swift deleted file mode 100644 index d4cf1b26..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/AppStore.swift +++ /dev/null @@ -1,760 +0,0 @@ -import ActivityKit -import CloudKit -import Foundation -import Observation -import UIKit - -/// The single `@Observable` store the views read. Owns the `BridgeConnection` -/// actor and keeps iroh types entirely out of the views: everything the UI -/// sees here is a plain, `Sendable` value type (`WorkspaceRow`/`SurfaceRow`/ -/// `AgentBadge`). -@MainActor -@Observable -final class AppStore { - enum Stage: Sendable { - case pairing - case workspaces - } - - enum ConnectionBanner: Sendable, Equatable { - case connecting - case connected - case reconnecting - case disconnected - - var label: String { - switch self { - case .connecting: String(localized: "remote.status.connecting", defaultValue: "Connecting…") - case .connected: String(localized: "remote.status.connected", defaultValue: "Connected") - case .reconnecting: String(localized: "remote.status.reconnecting", defaultValue: "Reconnecting…") - case .disconnected: String(localized: "remote.status.disconnected", defaultValue: "Not connected") - } - } - - var symbolName: String { - switch self { - case .connecting, .reconnecting: "arrow.triangle.2.circlepath" - case .connected: "checkmark.circle.fill" - case .disconnected: "xmark.circle" - } - } - } - - private(set) var stage: Stage = .pairing - private(set) var connectionBanner: ConnectionBanner = .disconnected - private(set) var observedPathDescription: String = String(localized: "connection.path.unknown", defaultValue: "unknown") - private(set) var workspaces: [WorkspaceRow] = [] - private(set) var surfacesByWorkspace: [String: [SurfaceRow]] = [:] - private(set) var lastSyncError: String? - private(set) var isConnecting = false - - /// M3: this iPhone's iCloud sign-in state, refreshed on init and on every foreground - /// reconciliation. Does **not** detect a mismatched Apple ID between this phone and the - /// paired Mac -- see `CloudKitPush.accountStatus()`'s doc comment. - private(set) var iCloudAccountStatus: CKAccountStatus = .couldNotDetermine - - var pairingTicketDraft: String - var pairingTokenDraft: String = "" - - private let connection = BridgeConnection() - private var currentTicket: String? - private var reconnectTask: Task<Void, Never>? - private var reconnectGeneration: UInt64 = 0 - - // MARK: - Live Activity state - // See the "Live Activity" section below for the full lifecycle. Kept as - // plain stored properties (not `@Observable`-tracked) since none of this - // is read by any view -- it only drives ActivityKit updates. - - private static let liveActivityUpdateInterval: TimeInterval = 2 - private static let liveActivityStaleInterval: TimeInterval = 5 * 60 - - private var liveActivity: Activity<AgentActivityAttributes>? - private var lastPushedActivityState: AgentActivityAttributes.ContentState? - private var lastActivityPushAt: Date = .distantPast - private var pendingActivityContentState: AgentActivityAttributes.ContentState? - private var pendingActivityUpdateTask: Task<Void, Never>? - private var mostRecentBlockedWorkspaceID: String? - /// Name of the paired Mac, shown in the Live Activity. Learned from the - /// pairing response and persisted, since trusted reconnects never resend it. - private var pairedMacName: String = PairingStore.loadMacName() - ?? String(localized: "liveActivity.macName", defaultValue: "your Mac") - // `nonisolated(unsafe)` because `deinit` on a @MainActor type runs - // nonisolated and must still unregister this. Written exactly once during - // init and read once in deinit, never concurrently, so the unsafe opt-out - // is accurate rather than a way to silence the checker. - private nonisolated(unsafe) var willTerminateObserver: NSObjectProtocol? - // `nonisolated(unsafe)` for the same reason as `willTerminateObserver` above. - private nonisolated(unsafe) var didBecomeActiveObserver: NSObjectProtocol? - private nonisolated(unsafe) var cloudKitAccountChangedObserver: NSObjectProtocol? - private var cloudKitAccountGeneration: UInt64 = 0 - - init() { - let savedTicket: String? - let credentialLoadError: Error? - do { - savedTicket = try PairingStore.loadTicket() - credentialLoadError = nil - } catch { - savedTicket = nil - credentialLoadError = error - } - pairingTicketDraft = savedTicket ?? "" - currentTicket = (savedTicket?.isEmpty == false) ? savedTicket : nil - if let credentialLoadError { - lastSyncError = String.localizedStringWithFormat( - String( - localized: "pairing.error.credentialLoadFailed", - defaultValue: "Could not load saved pairing credentials: %@" - ), - "\(credentialLoadError)" - ) - } - - Task { [weak self] in await self?.consumePhase() } - Task { [weak self] in await self?.consumePath() } - Task { [weak self] in await self?.consumeEvents() } - - // Best-effort signal for "the app is torn down": iOS almost always - // suspends rather than terminates a backgrounded app, so this fires - // less often in practice than a desktop app's quit, but it is the - // only such notification available without an AppDelegate adaptor. - willTerminateObserver = NotificationCenter.default.addObserver( - forName: UIApplication.willTerminateNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.endLiveActivity() - } - } - - if let ticket = currentTicket { - Task { [weak self] in await self?.attemptConnect(ticket: ticket, token: nil) } - } - - // M3 mandatory foreground reconciliation: silent CloudKit pushes are coalesced, - // throttled, and dropped entirely after a force-quit, so re-reading the record on - // every foreground is the only thing that guarantees the Live Activity (and the - // iCloud status banner) reflect reality rather than whatever pushes happened to land. - Task { [weak self] in await self?.reconcileFromCloudKit() } - didBecomeActiveObserver = NotificationCenter.default.addObserver( - forName: UIApplication.didBecomeActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.reconcileFromCloudKit() - } - } - cloudKitAccountChangedObserver = NotificationCenter.default.addObserver( - forName: .CKAccountChanged, - object: nil, - queue: .main - ) { [weak self] _ in - guard let self else { return } - Task { @MainActor in - await self.reconcileFromCloudKit() - } - } - } - - // `deinit` is nonisolated even on a @MainActor type, so it cannot read - // main-actor state. The observer token is captured at registration time - // and handed to a nonisolated static so teardown needs no isolated access. - deinit { - Self.removeObserver(willTerminateObserver) - Self.removeObserver(didBecomeActiveObserver) - Self.removeObserver(cloudKitAccountChangedObserver) - } - - private nonisolated static func removeObserver(_ token: NSObjectProtocol?) { - guard let token else { return } - NotificationCenter.default.removeObserver(token) - } - - // MARK: - User actions - - func connectManually() async { - cancelReconnect() - var ticket = pairingTicketDraft.trimmingCharacters(in: .whitespacesAndNewlines) - var token = pairingTokenDraft.trimmingCharacters(in: .whitespacesAndNewlines) - // A combined `programa-pair://` code pasted straight into the - // legacy ticket field is handled here rather than sent to iroh as a - // malformed ticket, which would otherwise fail with an opaque error. - if let parsed = PairingCode.parse(ticket) { - ticket = parsed.ticket - if token.isEmpty { token = parsed.token } - } - guard !ticket.isEmpty else { return } - do { - try PairingStore.saveTicket(ticket) - } catch { - lastSyncError = String.localizedStringWithFormat( - String( - localized: "pairing.error.credentialSaveFailed", - defaultValue: "Could not save pairing credentials: %@" - ), - "\(error)" - ) - return - } - currentTicket = ticket - pairingTicketDraft = ticket - pairingTokenDraft = "" - await attemptConnect(ticket: ticket, token: token.isEmpty ? nil : token) - } - - /// Fills the legacy ticket/token fields from a combined `programa-pair://` - /// code -- scanned via `QRScannerView` or pasted into the "Pairing code" - /// field. Returns `false` (leaving the legacy fields untouched) if `raw` - /// isn't a recognised combined code, so the caller can show a clear - /// error instead of silently doing nothing. - @discardableResult - func applyPairingCode(_ raw: String) -> Bool { - guard let parsed = PairingCode.parse(raw) else { return false } - pairingTicketDraft = parsed.ticket - pairingTokenDraft = parsed.token - return true - } - - func manualResync() async { - do { - try await resyncAll() - recomputeLiveActivity() - } catch { - lastSyncError = "\(error)" - } - } - - func sendPrompt(surfaceID: String, text: String) async throws { - _ = try await connection.sendPrompt(surfaceID: surfaceID, text: text) - } - - func returnToPairing() { - cancelReconnect() - stage = .pairing - } - - // MARK: - Derived state for views - - var sortedWorkspaces: [WorkspaceRow] { - workspaces.sorted { lhs, rhs in - let lhsBadge = badge(for: lhs.id) - let rhsBadge = badge(for: rhs.id) - if lhsBadge != rhsBadge { return lhsBadge > rhsBadge } - return lhs.index < rhs.index - } - } - - /// Worst-of a workspace's surfaces: blocked > working > idle. - func badge(for workspaceID: String) -> AgentBadge { - let surfaceBadges = surfacesByWorkspace[workspaceID]?.map(\.badge) ?? [] - if surfaceBadges.contains(.blocked) { return .blocked } - if surfaceBadges.contains(.working) { return .working } - return .idle - } - - func surfaces(for workspaceID: String) -> [SurfaceRow] { - surfacesByWorkspace[workspaceID] ?? [] - } - - func workspaceTitle(for workspaceID: String) -> String { - workspaces.first(where: { $0.id == workspaceID })?.title - ?? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") - } - - /// `nil` when iCloud is signed in and everything should work; otherwise a message to show - /// on the pairing screen. See `iCloudAccountStatus`'s doc comment for what this can't catch - /// (a mismatched Apple ID between this phone and the paired Mac). - var iCloudStatusMessage: String? { - switch iCloudAccountStatus { - case .available: - return nil - case .noAccount: - return String( - localized: "icloud.status.noAccount", - defaultValue: "Sign in to iCloud on this iPhone (Settings > [your name]) to get notified when an agent needs you while Programa is in the background." - ) - case .restricted: - return String( - localized: "icloud.status.restricted", - defaultValue: "iCloud is restricted on this iPhone, so background notifications won't work." - ) - case .temporarilyUnavailable: - return String( - localized: "icloud.status.temporarilyUnavailable", - defaultValue: "iCloud is temporarily unavailable, so background notifications may be delayed." - ) - case .couldNotDetermine: - return String(localized: "icloud.status.couldNotDetermine", defaultValue: "Could not check this iPhone's iCloud status.") - @unknown default: - return String(localized: "icloud.status.couldNotDetermine", defaultValue: "Could not check this iPhone's iCloud status.") - } - } - - // MARK: - Connection plumbing - - @discardableResult - private func attemptConnect(ticket: String, token: String?) async -> Bool { - isConnecting = true - lastSyncError = nil - defer { isConnecting = false } - do { - // Sent only on the pairing frame, so the Mac's device list can - // show a name instead of a 64-char hex EndpointID. Read here - // rather than inside the connection actor because UIDevice is - // main-actor bound and this store already is. - let deviceLabel = UIDevice.current.name - try await connection.connect( - pairingPayload: ticket, - pairingToken: token, - deviceLabel: deviceLabel.isEmpty ? nil : deviceLabel - ) - return true - } catch { - lastSyncError = "\(error)" - return false - } - } - - private func consumePhase() async { - for await phase in connection.phaseStream { - switch phase { - case .disconnected: - cancelReconnect() - connectionBanner = .disconnected - await endLiveActivity() - case .connecting, .pairing: - connectionBanner = .connecting - case .connected: - connectionBanner = .connected - lastSyncError = nil - cancelReconnect() - await handleConnected() - case let .failed(reason): - lastSyncError = reason - if Self.isPairingRejection(reason) { - // Permanent, not transient: this device is not on the Mac's - // allowlist, so reconnecting with the same (tokenless) - // ticket will be refused every time. Worse, while the retry - // loop runs the Connect button stays disabled, so the user - // cannot enter the token that would actually fix it. Stop, - // and put them back on the pairing screen. - cancelReconnect() - connectionBanner = .disconnected - stage = .pairing - } else { - connectionBanner = .reconnecting - scheduleReconnect() - } - } - } - } - - private func consumePath() async { - for await path in connection.pathStream { - observedPathDescription = path.description - } - } - - private func consumeEvents() async { - for await event in connection.events { - switch event { - case let .bridgeHello(payload): - // Arrives on every admission, so a renamed Mac corrects itself - // on the next connect rather than staying stale until re-pair. - if let name = payload.macName?.trimmingCharacters(in: .whitespacesAndNewlines), - !name.isEmpty, name != pairedMacName { - pairedMacName = name - PairingStore.saveMacName(name) - recomputeLiveActivity() - } - case let .agentState(payload): - applyAgentState(payload) - case .workspaceLifecycle: - // A workspace was created/closed/renamed — cheapest correct - // response is the same full resync a `dropped` event or a - // reconnect triggers. - await manualResync() - case .dropped: - // Per the wire contract: the server's event queue overflowed, - // so rebuild state from scratch instead of trusting the - // (partial) event stream. - await manualResync() - case .output: - break // Out of scope for the glance/unblock screens. - } - } - } - - private func applyAgentState(_ payload: WireAgentStateEvent) { - guard var rows = surfacesByWorkspace[payload.workspaceId] else { return } - guard let index = rows.firstIndex(where: { $0.id == payload.surfaceId }) else { return } - let wasBlocked = rows[index].badge == .blocked - rows[index].agentState = payload.state - rows[index].agentStateSource = payload.source - surfacesByWorkspace[payload.workspaceId] = rows - - let isBlockedNow = rows[index].badge == .blocked - let newlyBlocked = isBlockedNow && !wasBlocked - if newlyBlocked { - mostRecentBlockedWorkspaceID = payload.workspaceId - } - // A transition into "blocked" is the one case that must not sit - // behind the 2s coalescing window -- see `recomputeLiveActivity`. - recomputeLiveActivity(highPriority: newlyBlocked) - } - - private func handleConnected() async { - do { - try await resyncAll() - try await connection.subscribe(classes: ["agent_state", "workspace_lifecycle"]) - stage = .workspaces - recomputeLiveActivity() - // M3: (re)create the CloudKit query subscription once this device is trusted and - // talking to the Mac -- idempotent, so this is cheap on every reconnect. - await CloudKitPush.ensureSubscription() - } catch { - lastSyncError = "\(error)" - } - } - - /// Full resync: re-fetch `workspace.list` and, per workspace, - /// `surface.list`, then rebuild local state from that. Used on first - /// connect, on every reconnect, on a `dropped` event, on a - /// `workspace_lifecycle` event, and on pull-to-refresh — the wire - /// contract's documented recovery path, and there is no cheaper partial - /// alternative (no replay/resume-from-cursor). - private func resyncAll() async throws { - let wireWorkspaces = try await connection.listWorkspaces() - var newWorkspaces: [WorkspaceRow] = [] - var newSurfaces: [String: [SurfaceRow]] = [:] - for (offset, wireWorkspace) in wireWorkspaces.enumerated() { - let row = WorkspaceRow( - id: wireWorkspace.id, - title: wireWorkspace.title ?? String(localized: "workspace.title.untitled", defaultValue: "Untitled workspace"), - selected: wireWorkspace.selected ?? false, - index: wireWorkspace.index ?? offset - ) - newWorkspaces.append(row) - - let wireSurfaces = try await connection.listSurfaces(workspaceID: wireWorkspace.id) - newSurfaces[wireWorkspace.id] = wireSurfaces.map { surface in - SurfaceRow( - id: surface.id, - title: surface.title ?? String(localized: "surface.title.fallback", defaultValue: "Surface"), - focused: surface.focused ?? false, - agentState: surface.agentState, - agentStateSource: surface.agentStateSource - ) - } - } - workspaces = newWorkspaces - surfacesByWorkspace = newSurfaces - } - - /// `not_paired` / `pairing_failed` from the bridge, in whatever wrapping - /// the error arrives with. Matched on substring because the reason reaches - /// here as an interpolated error description rather than a typed code. - private nonisolated static func isPairingRejection(_ reason: String) -> Bool { - reason.contains("not_paired") || reason.contains("pairing_failed") - } - - private func scheduleReconnect() { - guard reconnectTask == nil, let ticket = currentTicket, !ticket.isEmpty else { return } - reconnectGeneration &+= 1 - let generation = reconnectGeneration - reconnectTask = Task { [weak self] in - var delaySeconds = 2 - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(delaySeconds)) - } catch { - return - } - guard let outcome = await self?.performReconnectAttempt( - ticket: ticket, - generation: generation - ) else { - return - } - switch outcome { - case .stale: - return - case .connected: - self?.finishReconnect(generation: generation) - return - case .retry: - delaySeconds = min(delaySeconds * 2, 30) - } - } - } - } - - private enum ReconnectAttemptOutcome { - case stale - case connected - case retry - } - - private func performReconnectAttempt( - ticket: String, - generation: UInt64 - ) async -> ReconnectAttemptOutcome { - guard reconnectGeneration == generation, !Task.isCancelled else { return .stale } - let connected = await attemptConnect(ticket: ticket, token: nil) - guard reconnectGeneration == generation, !Task.isCancelled else { return .stale } - return connected ? .connected : .retry - } - - private func finishReconnect(generation: UInt64) { - guard reconnectGeneration == generation else { return } - reconnectTask = nil - } - - private func cancelReconnect() { - reconnectGeneration &+= 1 - reconnectTask?.cancel() - reconnectTask = nil - } - - // MARK: - Live Activity - // - // Lifecycle: started the first time `handleConnected()`/`manualResync()` - // sees at least one workspace while connected; updated as `agent_state` - // events land; ended on an explicit disconnect or app teardown. - // - // Coalescing: ActivityKit budgets update frequency, and `agent_state` - // events can churn quickly (an agent flipping working/idle/blocked in a - // tight loop). `recomputeLiveActivity` only ever pushes a genuinely - // different `ContentState` (`Hashable` equality against the last pushed - // value), and rate-limits pushes to once per - // `liveActivityUpdateInterval` (2s), coalescing any intermediate changes - // into the single latest value sent when the window elapses. The one - // exception is a transition into `blockedCount > 0`, which is high - // priority and always sent immediately -- a human is needed right now, - // and that must not sit behind the coalescing window. - // - // Known limitation: while the app is suspended, the iroh connection - // drops and no further updates can be pushed, so the Live Activity - // freezes on its last known state. `staleDate` (5 minutes out) lets the - // system visually de-emphasize it once it goes stale. Fixing this for - // real needs a remote-push (APNs) update path -- ActivityKit supports - // `pushType: .token` plus a server that POSTs content updates to Apple's - // Live Activity push endpoint -- which does not exist yet for Programa. - - private func ensureLiveActivityStarted() { - guard liveActivity == nil else { return } - guard !workspaces.isEmpty else { return } - guard ActivityAuthorizationInfo().areActivitiesEnabled else { - // Record rather than crash -- the user simply disabled Live - // Activities (Settings > Face ID & Passcode, or per-app), which - // is a normal, expected state, not a bridge/sync failure. - lastSyncError = String( - localized: "liveActivity.error.disabled", - defaultValue: "Live Activities are disabled; agent status won't appear on the Lock Screen." - ) - return - } - - let attributes = AgentActivityAttributes( - // The wire contract has no field for the paired Mac's device - // name today (only an opaque pairing ticket) -- this is a - // placeholder until a future milestone plumbs one through, e.g., - // `system.ping`. - macName: pairedMacName - ) - let initialState = deriveActivityContentState() - let content = ActivityContent( - state: initialState, - staleDate: Date().addingTimeInterval(Self.liveActivityStaleInterval) - ) - - do { - // No `pushType:` -- there is no APNs push path yet (see the - // limitation noted above), so this activity can only be updated - // locally, for as long as the app process is alive to do so. - liveActivity = try Activity<AgentActivityAttributes>.request( - attributes: attributes, - content: content - ) - lastPushedActivityState = initialState - lastActivityPushAt = Date() - } catch { - lastSyncError = String.localizedStringWithFormat( - String(localized: "liveActivity.error.startFailed", defaultValue: "Could not start Live Activity: %@"), - "\(error)" - ) - } - } - - private func recomputeLiveActivity(highPriority: Bool = false) { - ensureLiveActivityStarted() - guard let liveActivity else { return } - - let newState = deriveActivityContentState() - guard newState != lastPushedActivityState else { return } - - let elapsedSinceLastPush = Date().timeIntervalSince(lastActivityPushAt) - if highPriority || elapsedSinceLastPush >= Self.liveActivityUpdateInterval { - pendingActivityUpdateTask?.cancel() - pendingActivityUpdateTask = nil - pendingActivityContentState = nil - pushActivityUpdate(newState, activityID: liveActivity.id) - } else { - // Coalesce: remember the latest desired state and let the - // already-scheduled (or newly-scheduled) flush send it once the - // rate-limit window elapses. - pendingActivityContentState = newState - scheduleCoalescedActivityUpdate(delay: Self.liveActivityUpdateInterval - elapsedSinceLastPush) - } - } - - private func scheduleCoalescedActivityUpdate(delay: TimeInterval) { - guard pendingActivityUpdateTask == nil else { return } - pendingActivityUpdateTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(max(0, delay))) - guard let self, !Task.isCancelled else { return } - await self.flushPendingActivityUpdate() - } - } - - private func flushPendingActivityUpdate() async { - pendingActivityUpdateTask = nil - guard let liveActivity, let pending = pendingActivityContentState else { return } - pendingActivityContentState = nil - guard pending != lastPushedActivityState else { return } - pushActivityUpdate(pending, activityID: liveActivity.id) - } - - private func pushActivityUpdate( - _ state: AgentActivityAttributes.ContentState, - activityID: String - ) { - lastPushedActivityState = state - lastActivityPushAt = Date() - let staleDate = Date().addingTimeInterval(Self.liveActivityStaleInterval) - // Only the id and a Sendable ContentState cross into the task; see - // `updateActivity` for why the `Activity` handle itself cannot. - Task { - await Self.updateActivity(id: activityID, state: state, staleDate: staleDate) - } - } - - private func endLiveActivity() async { - pendingActivityUpdateTask?.cancel() - pendingActivityUpdateTask = nil - pendingActivityContentState = nil - guard let activityID = liveActivity?.id else { return } - liveActivity = nil - let finalState = lastPushedActivityState ?? deriveActivityContentState() - lastPushedActivityState = nil - mostRecentBlockedWorkspaceID = nil - await Self.endActivity(id: activityID, finalState: finalState) - } - - // ActivityKit calls live in nonisolated statics that take only `Sendable` - // values (an id and a ContentState) and re-resolve the `Activity` locally. - // `Activity` is not Sendable, so awaiting one of its methods from this - // @MainActor class sends actor-isolated state out of its region and Swift 6 - // rejects it. Resolving inside a nonisolated context keeps the handle local. - private nonisolated static func endActivity( - id: String, - finalState: AgentActivityAttributes.ContentState - ) async { - guard let live = Activity<AgentActivityAttributes>.activities - .first(where: { $0.id == id }) - else { return } - await live.end( - ActivityContent(state: finalState, staleDate: nil), - dismissalPolicy: .immediate - ) - } - - private nonisolated static func updateActivity( - id: String, - state: AgentActivityAttributes.ContentState, - staleDate: Date - ) async { - guard let live = Activity<AgentActivityAttributes>.activities - .first(where: { $0.id == id }) - else { return } - await live.update(ActivityContent(state: state, staleDate: staleDate)) - } - - // MARK: - CloudKit reconciliation (M3) - // - // The bridge-driven path above (`recomputeLiveActivity`, fed by live `agent_state` events) - // is the precise, real-time source of truth while connected. CloudKit is the backstop for - // when it isn't: a silent push wakes the app (`AppDelegate`/`LiveActivityCloudKitBridge`) - // or, failing that, this reconciliation runs on every foreground regardless. Both paths - // write through the same `liveActivity`/`lastPushedActivityState` bookkeeping so the - // bridge-driven coalescing above stays correct afterward. - - /// Called on `AppStore` init and on every `UIApplication.didBecomeActiveNotification`. - /// Refreshes the iCloud status banner and rebuilds Live Activity state from the last - /// summary the Mac wrote, independent of whether the iroh bridge is currently connected. - func reconcileFromCloudKit() async { - cloudKitAccountGeneration &+= 1 - let generation = cloudKitAccountGeneration - let accountStatus = await CloudKitPush.accountStatus() - guard generation == cloudKitAccountGeneration else { return } - iCloudAccountStatus = accountStatus - guard accountStatus == .available else { return } - await CloudKitPush.ensureSubscription() - guard generation == cloudKitAccountGeneration else { return } - guard let summary = await CloudKitPush.fetchSummary() else { return } - guard generation == cloudKitAccountGeneration else { return } - applyCloudKitSummary(summary) - } - - private func applyCloudKitSummary(_ summary: CloudKitPush.Summary) { - ensureLiveActivityStarted() - guard let liveActivity else { return } - - let newState = AgentActivityAttributes.ContentState( - blockedCount: summary.blockedCount, - workingCount: summary.workingCount, - headlineWorkspace: summary.blockedCount > 0 ? summary.mostRecentBlockedWorkspaceTitle : nil - ) - guard newState != lastPushedActivityState else { return } - // CloudKit reconciliation always wins immediately rather than going through the 2s - // coalescing window -- it only runs at most once per foreground/background-wake, so - // there's no churn to coalesce against. - pendingActivityUpdateTask?.cancel() - pendingActivityUpdateTask = nil - pendingActivityContentState = nil - pushActivityUpdate(newState, activityID: liveActivity.id) - } - - private func deriveActivityContentState() -> AgentActivityAttributes.ContentState { - let allSurfaces = surfacesByWorkspace.values.flatMap { $0 } - let blockedCount = allSurfaces.filter { $0.badge == .blocked }.count - let workingCount = allSurfaces.filter { $0.badge == .working }.count - return AgentActivityAttributes.ContentState( - blockedCount: blockedCount, - workingCount: workingCount, - headlineWorkspace: blockedCount > 0 ? headlineWorkspaceTitle() : nil - ) - } - - /// The workspace that most recently transitioned into `blocked`, as long - /// as it is still blocked. If that workspace has since cleared (or was - /// never set -- e.g. right after a full resync, which has no transition - /// history), falls back to any currently-blocked workspace and adopts it - /// as the new sticky pointer. - private func headlineWorkspaceTitle() -> String? { - if let id = mostRecentBlockedWorkspaceID, badge(for: id) == .blocked { - return workspaceTitle(for: id) - } - guard let fallback = sortedWorkspaces.first(where: { badge(for: $0.id) == .blocked }) else { - mostRecentBlockedWorkspaceID = nil - return nil - } - mostRecentBlockedWorkspaceID = fallback.id - return fallback.title - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift b/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift deleted file mode 100644 index 87a8a597..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/AppVersion.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -/// The companion's own version and build number, read from the bundle. -/// -/// This exists because there was previously no way, from the phone, to tell -/// which build was installed. The app displayed nothing, every TestFlight build -/// carried the same version string, and the build number is a bare CI run id -- -/// so "is this the current build?" was unanswerable without a Mac and Xcode. -enum AppVersion { - /// `CFBundleShortVersionString`, e.g. "1.0". - static var marketing: String { - bundleString("CFBundleShortVersionString") - } - - /// `CFBundleVersion`. Matches the GitHub Actions run id that produced the - /// build, so it can be compared directly against the run in the repo. - static var build: String { - bundleString("CFBundleVersion") - } - - /// e.g. "1.0 (3057430199401)". - static var displayString: String { - "\(marketing) (\(build))" - } - - private static func bundleString(_ key: String) -> String { - guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String, - !value.isEmpty else { - return String(localized: "about.version.unknown", defaultValue: "unknown") - } - return value - } -} - -/// A single row showing the installed version. Selectable, because the point of -/// it is copying the build number somewhere else to compare. -struct AppVersionRow: View { - var body: some View { - LabeledContent( - String(localized: "about.version.label", defaultValue: "Version"), - value: AppVersion.displayString - ) - .font(.footnote) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png deleted file mode 100644 index 505b4ed1..00000000 Binary files a/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and /dev/null differ diff --git a/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index b773ff25..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "images" : [ - { - "filename" : "AppIcon-1024.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { "author" : "xcode", "version" : 1 } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/Contents.json b/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/Contents.json deleted file mode 100644 index d8b757af..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/Assets.xcassets/Contents.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "info" : { "author" : "xcode", "version" : 1 } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift b/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift deleted file mode 100644 index 52b70d29..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/BridgeConnection.swift +++ /dev/null @@ -1,755 +0,0 @@ -import Foundation -import IrohLib - -/// Long-lived connection actor for the Programa mobile bridge wire protocol: -/// newline-delimited JSON-RPC plus unsolicited event frames, all over one -/// iroh bidirectional stream. Supersedes the earlier one-shot probe -/// (`SpikeClient.performProbe`): this owns the iroh `Connection`/stream for -/// as long as the app is connected, runs a continuous read loop that -/// demultiplexes response lines (matched by `id`) from event frames -/// (`"event"` key, no `id`), and exposes typed async request methods plus -/// an `AsyncStream` of decoded events. -/// -/// Concurrency note: every iroh type (`Endpoint`, `Connection`, `SendStream`, -/// `RecvStream`) lives entirely in this actor's isolated storage and is -/// never handed across an isolation boundary. Background loops are spawned -/// as `Task { [weak self] in await self?.someActorMethod() }`, which re-read -/// actor-isolated state fresh inside that method rather than capturing a -/// local non-Sendable value directly in the closure. That is the fix for -/// the class of error the original spike's `performProbe` hit (a -/// non-Sendable `self` crossing an isolation boundary from a `@MainActor` -/// call site) — solved here with actor isolation instead of -/// `@unchecked Sendable`. -actor BridgeConnection { - enum Phase: Sendable { - case disconnected - case connecting - case pairing - case connected - case failed(String) - } - - // Matches the ALPN the bridge (tools/mobile-spike/Sources/iroh-spike/ - // Bridge.swift, `spikeALPN`) currently listens on. Duplicated rather - // than imported -- separate build graphs, same convention already used - // by this target's `SpikeError`/`PathClassifier` duplication. - // Must match the in-app bridge's ALPN exactly - // (Sources/MobileBridge/MobileBridgeListener.swift). A mismatch is not a - // soft failure: iroh aborts the handshake with "peer doesn't support any - // known protocol", which reads like a transport fault rather than a - // version skew between the two halves of this feature. - private static let alpn = Data("programa/mobile-bridge/1".utf8) - private static let pathPollInterval: Duration = .seconds(3) - private static let pairingReplyTimeout: Duration = .seconds(15) - - private(set) var phase: Phase = .disconnected - private(set) var observedPath: ObservedPath = .unavailable - - nonisolated let phaseStream: AsyncStream<Phase> - nonisolated let pathStream: AsyncStream<ObservedPath> - nonisolated let events: AsyncStream<BridgeEvent> - - private let phaseContinuation: AsyncStream<Phase>.Continuation - private let pathContinuation: AsyncStream<ObservedPath>.Continuation - private let eventContinuation: AsyncStream<BridgeEvent>.Continuation - - private var endpoint: Endpoint? - private var connection: Connection? - private var sendStream: SendStream? - private var recvStream: RecvStream? - private var lineFramer = BoundedLineFramer() - - private var readLoopTask: Task<Void, Never>? - private var heartbeatTask: Task<Void, Never>? - private var pathLoopTask: Task<Void, Never>? - - private struct PendingRequest { - let continuation: CheckedContinuation<Data, Error> - let timeoutTask: Task<Void, Never> - } - - private var nextRequestId = 1 - private var pending: [Int: PendingRequest] = [:] - - init() { - var phaseContinuation: AsyncStream<Phase>.Continuation! - phaseStream = AsyncStream { phaseContinuation = $0 } - self.phaseContinuation = phaseContinuation - - var pathContinuation: AsyncStream<ObservedPath>.Continuation! - pathStream = AsyncStream { pathContinuation = $0 } - self.pathContinuation = pathContinuation - - var eventContinuation: AsyncStream<BridgeEvent>.Continuation! - events = AsyncStream { eventContinuation = $0 } - self.eventContinuation = eventContinuation - } - - func pendingRequestCountForTesting() -> Int { - pending.count - } - - // MARK: - Connection lifecycle - - /// Connects (or reconnects) to a bridge. `pairingToken` is only needed - /// the first time a device pairs — pass `nil` on later connections and - /// the phone's already-allowlisted EndpointID skips the pairing frame - /// entirely, per the wire contract. - func connect(pairingPayload: String, pairingToken: String?, deviceLabel: String? = nil) async throws { - await teardownConnection() - setPhase(.connecting) - - let ticket: EndpointTicket - do { - ticket = try EndpointTicket.fromString(str: pairingPayload) - } catch { - setPhase(.failed(String(localized: "bridge.error.invalidTicket", defaultValue: "invalid pairing ticket"))) - throw BridgeError.invalidTicket - } - let targetAddress = ticket.endpointAddr() - - let secretKey: Data - do { - secretKey = try SecretKeyStore.loadOrCreate() - } catch { - setPhase(.failed(String(localized: "bridge.error.deviceIdentityFailed", defaultValue: "could not load device identity"))) - throw error - } - - let options = EndpointOptions( - preset: presetN0(), - bindAddr: "0.0.0.0:0", - secretKey: secretKey, - alpns: [Self.alpn], - relayMode: RelayMode.defaultMode(), - portMappingEnabled: true, - deferNatTraversalUntilAuthorized: true, - initialMaxConcurrentBiStreams: 0, - initialMaxConcurrentUniStreams: 0 - ) - - var boundEndpoint: Endpoint? - var establishedConnection: Connection? - do { - let newEndpoint = try await Endpoint.bind(options: options) - boundEndpoint = newEndpoint - // Bound the dial. A ticket that points at a bridge which no longer - // exists -- a Mac that restarted, or an old pairing -- otherwise - // parks here indefinitely and the UI sits on "Connecting…" forever - // with no error and no way out. `Endpoint.connect` routes through - // `irohConnectWithTaskCancellation`, so unlike the raw stream reads - // it genuinely honours cancellation. - let newConnection = try await withCooperativeTimeout(seconds: 20) { - try await newEndpoint.connect(addr: targetAddress, alpn: Self.alpn) - } - establishedConnection = newConnection - try newConnection.setMaxConcurrentBiStreams(count: 1) - try newConnection.setMaxConcurrentUniStreams(count: 0) - try await newConnection.authorizeNatTraversal() - } catch { - if let establishedConnection { - try? establishedConnection.close(errorCode: 0, reason: Data()) - } - if case BridgeError.timedOut = error { - if let boundEndpoint { try? await boundEndpoint.close() } - setPhase(.failed( - String( - localized: "bridge.error.unreachable", - defaultValue: "could not reach that Mac — is Programa running with Settings ▸ Phone turned on?" - ) - )) - throw error - } - if let boundEndpoint { - try? await boundEndpoint.close() - } - setPhase(.failed("\(error)")) - throw error - } - - guard let newEndpoint = boundEndpoint, let newConnection = establishedConnection else { - setPhase(.failed(String(localized: "bridge.error.setupFailed", defaultValue: "connection setup failed"))) - throw BridgeError.disconnected - } - - do { - let stream = try await newConnection.openBi() - endpoint = newEndpoint - connection = newConnection - sendStream = stream.send() - recvStream = stream.recv() - lineFramer = BoundedLineFramer() - } catch { - try? newConnection.close(errorCode: 0, reason: Data()) - try? await newEndpoint.close() - setPhase(.failed("\(error)")) - throw error - } - - if let pairingToken, !pairingToken.isEmpty { - setPhase(.pairing) - do { - let pairPayload = try JSONEncoder().encode(PairRequest(pair: pairingToken, label: deviceLabel)) - try await writeLine(pairPayload) - let replyDeadline = InitialPairingReplyDeadline() - guard let replyLine = try await replyDeadline.run( - timeout: Self.pairingReplyTimeout, - read: { [weak self] in - guard let self else { return nil } - return try await self.nextBufferedLine() - }, - abort: { [weak self] in - await self?.teardownConnection() - } - ) else { - throw BridgeError.disconnected - } - let reply = try JSONDecoder().decode(PairResponse.self, from: replyLine) - guard reply.ok else { - throw BridgeError.rpc(code: reply.error?.code ?? "pairing_failed", message: reply.error?.message) - } - } catch { - await teardownConnection() - setPhase(.failed("\(error)")) - throw error - } - } - - let initialPath = await PathClassifier.waitForSelectedPath(connection: newConnection, timeout: .seconds(5)) - observedPath = initialPath - pathContinuation.yield(initialPath) - - startReadLoop() - startPathLoop() - - // Do NOT report `.connected` merely because the QUIC stream opened. - // On the already-trusted path no pairing frame is sent, so an - // unrecognised device would sit here looking "connected" while the - // bridge had actually answered `not_paired` -- the failure only - // surfaced later, on the first real request. That false positive cost - // real debugging time. - // - // A round-trip `system.ping` proves the whole chain in one call: the - // stream works, the bridge admitted this device, the relay to - // Programa's socket is up, and Programa answered. Only then is - // "connected" true in any sense the user cares about. - do { - _ = try await performRequestUnchecked(method: "system.ping", params: [:]) - } catch { - await teardownConnection() - setPhase(.failed( - String.localizedStringWithFormat( - String(localized: "bridge.error.notAdmitted", defaultValue: "not admitted by the Mac: %@"), - "\(error)" - ) - )) - throw error - } - - setPhase(.connected) - startHeartbeat() - } - - func disconnect() async { - await teardownConnection() - setPhase(.disconnected) - } - - // MARK: - Typed requests - // Only the methods this app's three screens actually use. Every method - // name below is in the wire contract's permitted list; the bridge - // rejects anything else with `forbidden`. - - func listWorkspaces() async throws -> [WireWorkspace] { - let data = try await performRequest(method: "workspace.list", params: [:]) - let result = try decodeEnvelope(data, as: WireWorkspaceListResult.self) - return result.workspaces ?? [] - } - - func listSurfaces(workspaceID: String) async throws -> [WireSurface] { - let data = try await performRequest( - method: "surface.list", - params: ["workspace_id": .string(workspaceID)] - ) - let result = try decodeEnvelope(data, as: WireSurfaceListResult.self) - return result.surfaces ?? [] - } - - func subscribe(classes: [String]) async throws { - let data = try await performRequest(method: "subscribe", params: ["classes": .stringArray(classes)]) - _ = try decodeEnvelope(data, as: WireSubscribeResult.self) - } - - @discardableResult - func sendPrompt(surfaceID: String, text: String) async throws -> WireAgentPromptResult { - let data = try await performRequest( - method: "agent.prompt", - params: ["surface_id": .string(surfaceID), "text": .string(text)] - ) - return try decodeEnvelope(data, as: WireAgentPromptResult.self) - } - - // MARK: - Phase / path helpers - - private func setPhase(_ newPhase: Phase) { - phase = newPhase - phaseContinuation.yield(newPhase) - } - - /// Races only cancellation-cooperative operations against a deadline. - /// RPC requests use ID-owned deadlines below because cancelling a task - /// suspended on a checked continuation does not resume that continuation. - private func withCooperativeTimeout<T: Sendable>( - seconds: Double, - _ operation: @escaping @Sendable () async throws -> T - ) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(for: .seconds(seconds)) - throw BridgeError.timedOut - } - defer { group.cancelAll() } - guard let first = try await group.next() else { throw BridgeError.timedOut } - return first - } - } - - /// A subscribed dashboard sends nothing and may receive nothing for long - /// stretches -- no agent changing state means no event frames. QUIC closes - /// an idle connection, which showed up in the bridge log as repeated - /// `ConnectionLost(TimedOut)` shortly after every `subscribe`. A cheap - /// periodic ping keeps the path warm and doubles as liveness detection. - private func startHeartbeat() { - heartbeatTask?.cancel() - heartbeatTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(15)) - if Task.isCancelled { return } - await self?.sendHeartbeat() - } - } - } - - private func sendHeartbeat() async { - guard case .connected = phase else { return } - do { - _ = try await performRequestUnchecked(method: "system.ping", params: [:]) - } catch { - // The read loop owns disconnect handling; surface the phase here so - // a silently dead connection doesn't keep looking healthy. - setPhase(.failed( - String.localizedStringWithFormat( - String(localized: "bridge.error.connectionLost", defaultValue: "connection lost: %@"), - "\(error)" - ) - )) - } - } - - private func startReadLoop() { - readLoopTask?.cancel() - readLoopTask = Task { [weak self] in - await self?.runReadLoop() - } - } - - private func startPathLoop() { - pathLoopTask?.cancel() - pathLoopTask = Task { [weak self] in - await self?.runPathLoop() - } - } - - private func runReadLoop() async { - while !Task.isCancelled { - do { - guard let line = try await nextBufferedLine() else { - await handleDisconnect( - reason: String(localized: "bridge.error.connectionClosed", defaultValue: "connection closed") - ) - return - } - handleLine(line) - } catch { - if Task.isCancelled { return } - await handleDisconnect(reason: "\(error)") - return - } - } - } - - private func runPathLoop() async { - while !Task.isCancelled { - guard case .connected = phase, let connection else { return } - let classified = PathClassifier.classify(connection.paths()) - if classified != observedPath { - observedPath = classified - pathContinuation.yield(classified) - } - do { - try await Task.sleep(for: Self.pathPollInterval) - } catch { - return - } - } - } - - // MARK: - Framing - - private func nextBufferedLine() async throws -> Data? { - guard let recvStream else { return nil } - return try await lineFramer.nextLine { sizeLimit in - try await recvStream.read(sizeLimit: sizeLimit) - } - } - - private func handleLine(_ line: Data) { - guard !line.isEmpty else { return } - guard let object = try? JSONSerialization.jsonObject(with: line) as? [String: Any] else { - return - } - if let eventName = object["event"] as? String { - emitEvent(name: eventName, line: line) - return - } - // A frame with no `id` and no `event` is a CONNECTION-level rejection, - // not a reply to anything: the bridge sends `{"ok":false,"error": - // {"code":"not_paired"}}` and hangs up before this client has sent a - // single request. Previously this fell through the `id` guard below and - // was silently dropped, so the app sat on "Connecting…" until an - // unrelated 15s ping timeout fired, then retried forever -- never - // surfacing the one fact that mattered: this device is not paired. - if object["id"] == nil, - let ok = object["ok"] as? Bool, ok == false { - let code = (object["error"] as? [String: Any])?["code"] as? String ?? "rejected" - failAllPending(with: BridgeError.rpc(code: code, message: nil)) - setPhase(.failed(code)) - return - } - guard let rawId = object["id"] else { return } - let id: Int? - if let intId = rawId as? Int { - id = intId - } else if let numberId = rawId as? NSNumber { - id = numberId.intValue - } else { - id = nil - } - guard let id, let request = pending.removeValue(forKey: id) else { return } - request.timeoutTask.cancel() - request.continuation.resume(returning: line) - } - - private func failAllPending(with error: Error) { - let outstanding = pending - pending.removeAll() - for (_, request) in outstanding { - request.timeoutTask.cancel() - request.continuation.resume(throwing: error) - } - } - - private func emitEvent(name: String, line: Data) { - let decoder = JSONDecoder() - switch name { - case "bridge_hello": - guard let payload = try? decoder.decode(WireBridgeHelloEvent.self, from: line) else { return } - eventContinuation.yield(.bridgeHello(payload)) - case "agent_state": - guard let payload = try? decoder.decode(WireAgentStateEvent.self, from: line) else { return } - eventContinuation.yield(.agentState(payload)) - case "output": - guard let payload = try? decoder.decode(WireOutputEvent.self, from: line) else { return } - eventContinuation.yield(.output(payload)) - case "workspace_lifecycle": - guard let payload = try? decoder.decode(WireWorkspaceLifecycleEvent.self, from: line) else { return } - eventContinuation.yield(.workspaceLifecycle(payload)) - case "dropped": - guard let payload = try? decoder.decode(WireDroppedEvent.self, from: line) else { return } - eventContinuation.yield(.dropped(payload.count)) - default: - break - } - } - - private func handleDisconnect(reason: String) async { - await teardownConnection() - setPhase(.failed(reason)) - } - - private func teardownConnection() async { - heartbeatTask?.cancel() - heartbeatTask = nil - readLoopTask?.cancel() - readLoopTask = nil - pathLoopTask?.cancel() - pathLoopTask = nil - - if let connection { - try? connection.close(errorCode: 0, reason: Data("client_teardown".utf8)) - } - if let endpoint { - try? await endpoint.close() - } - connection = nil - endpoint = nil - sendStream = nil - recvStream = nil - lineFramer = BoundedLineFramer() - - let pendingCopy = pending - pending.removeAll() - for (_, request) in pendingCopy { - request.timeoutTask.cancel() - request.continuation.resume(throwing: BridgeError.disconnected) - } - } - - private func writeLine(_ data: Data) async throws { - guard let sendStream else { throw BridgeError.notConnected } - var framed = data - framed.append(0x0A) - try await sendStream.writeAll(buf: framed) - } - - // MARK: - Request/response plumbing - - private func performRequest(method: String, params: [String: RPCParam]) async throws -> Data { - guard case .connected = phase else { throw BridgeError.notConnected } - return try await performRequestUnchecked(method: method, params: params) - } - - /// Same as `performRequest` but without the `.connected` phase guard, so - /// the admission ping issued during `connect()` -- which by definition runs - /// before the phase is `.connected` -- can use the normal request/response - /// machinery. Everything else must go through `performRequest`. - private func performRequestUnchecked( - method: String, - params: [String: RPCParam], - timeout: Duration = .seconds(15) - ) async throws -> Data { - let id = nextRequestId - nextRequestId += 1 - let request = RPCRequest(id: id, method: method, params: params) - let payload: Data - do { - payload = try JSONEncoder().encode(request) - } catch { - throw BridgeError.encodingFailed - } - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in - let timeoutTask = Task { [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - await self?.failPendingRequest(id: id, error: BridgeError.timedOut) - } - pending[id] = PendingRequest( - continuation: continuation, - timeoutTask: timeoutTask - ) - Task { [weak self] in - await self?.performWrite(id: id, payload: payload) - } - } - } onCancel: { - Task { [weak self] in - await self?.failPendingRequest(id: id, error: CancellationError()) - } - } - } - - private func failPendingRequest(id: Int, error: Error) { - guard let request = pending.removeValue(forKey: id) else { return } - request.timeoutTask.cancel() - request.continuation.resume(throwing: error) - } - - private func performWrite(id: Int, payload: Data) async { - do { - try await writeLine(payload) - } catch { - failPendingRequest(id: id, error: error) - } - } - - private func decodeEnvelope<R: Decodable>(_ data: Data, as type: R.Type) throws -> R { - let envelope = try JSONDecoder().decode(ResponseEnvelope<R>.self, from: data) - guard envelope.ok else { - throw BridgeError.rpc(code: envelope.error?.code ?? "unknown_error", message: envelope.error?.message) - } - guard let result = envelope.result else { - throw BridgeError.malformedResponse - } - return result - } -} - -/// Races the initial pairing reply against timeout and caller cancellation without relying on -/// the underlying iroh read to cooperate with task cancellation. The abort action closes the -/// transport before the winning error is resumed, which releases a read blocked on a silent peer. -final class InitialPairingReplyDeadline: @unchecked Sendable { - private enum Completion: @unchecked Sendable { - case value(Data?) - case failure(Error) - } - - private let lock = NSLock() - private var continuation: CheckedContinuation<Data?, Error>? - private var completion: Completion? - private var completionRequiresAbort = false - private var abortAction: (@Sendable () async -> Void)? - private var readTask: Task<Void, Never>? - private var timeoutTask: Task<Void, Never>? - - func run( - timeout: Duration, - read: @escaping @Sendable () async throws -> Data?, - abort: @escaping @Sendable () async -> Void - ) async throws -> Data? { - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - guard install(continuation: continuation, abort: abort) else { return } - storeReadTask(Task { [weak self] in - do { - let value = try await read() - self?.resolve(.value(value), abort: false) - } catch { - self?.resolve(.failure(error), abort: false) - } - }) - storeTimeoutTask(Task { [weak self] in - do { - try await Task.sleep(for: timeout) - } catch { - return - } - self?.resolve(.failure(BridgeError.timedOut), abort: true) - }) - } - } onCancel: { - self.resolve(.failure(CancellationError()), abort: true) - } - } - - private func install( - continuation: CheckedContinuation<Data?, Error>, - abort: @escaping @Sendable () async -> Void - ) -> Bool { - lock.lock() - if let completion { - let shouldAbort = completionRequiresAbort - lock.unlock() - Task { - if shouldAbort { await abort() } - Self.resume(continuation, with: completion) - } - return false - } - self.continuation = continuation - abortAction = abort - lock.unlock() - return true - } - - private func storeReadTask(_ task: Task<Void, Never>) { - lock.lock() - if completion == nil { - readTask = task - lock.unlock() - } else { - lock.unlock() - task.cancel() - } - } - - private func storeTimeoutTask(_ task: Task<Void, Never>) { - lock.lock() - if completion == nil { - timeoutTask = task - lock.unlock() - } else { - lock.unlock() - task.cancel() - } - } - - private func resolve(_ completion: Completion, abort shouldAbort: Bool) { - lock.lock() - guard self.completion == nil else { - lock.unlock() - return - } - self.completion = completion - completionRequiresAbort = shouldAbort - let continuation = self.continuation - self.continuation = nil - let abortAction = self.abortAction - self.abortAction = nil - let readTask = self.readTask - self.readTask = nil - let timeoutTask = self.timeoutTask - self.timeoutTask = nil - lock.unlock() - - readTask?.cancel() - timeoutTask?.cancel() - guard let continuation else { return } - Task { - if shouldAbort, let abortAction { - await abortAction() - } - Self.resume(continuation, with: completion) - } - } - - private static func resume( - _ continuation: CheckedContinuation<Data?, Error>, - with completion: Completion - ) { - switch completion { - case .value(let value): - continuation.resume(returning: value) - case .failure(let error): - continuation.resume(throwing: error) - } - } - - private func resume( - _ continuation: CheckedContinuation<Data?, Error>, - with completion: Completion - ) { - Self.resume(continuation, with: completion) - } -} - -private struct ResponseEnvelope<R: Decodable>: Decodable { - let ok: Bool - let result: R? - let error: WireErrorPayload? -} - -private struct PairRequest: Encodable { - let pair: String - /// Human-readable device name so the Mac's paired-device list can show - /// "Franco's iPhone" instead of a 64-char hex EndpointID. Optional on the - /// wire -- the bridge falls back to a placeholder when absent. - let label: String? -} - -private struct PairResponse: Decodable { - let ok: Bool - let paired: Bool? - let error: WireErrorPayload? -} - -private struct RPCRequest: Encodable { - let id: Int - let method: String - let params: [String: RPCParam] -} diff --git a/ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift b/ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift deleted file mode 100644 index 5ab86ec9..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift +++ /dev/null @@ -1,145 +0,0 @@ -import CloudKit -import Foundation - -/// Reads the small agent-activity summary the Mac's `MobileBridgePush` -/// (`Sources/MobileBridge/MobileBridgePush.swift`) writes to the user's own iCloud private -/// database, and owns the `CKQuerySubscription` that lets Apple wake this app with a push when -/// that record changes. -/// -/// Record shape (container id, record type/name, field names) is duplicated here rather than -/// shared with the Mac target -- separate build graphs, same convention already used for the -/// mobile-bridge ALPN (`BridgeConnection.alpn` vs `MobileBridgeListener`'s -/// `mobileBridgeALPN`). Any change to the Mac's field names must be mirrored here by hand. -enum CloudKitPush { - static let containerIdentifier = "iCloud.com.darkroom.programa" - static let recordType = "AgentStatus" - static let recordName = "agent-status-summary" - static let subscriptionID = "agent-status-subscription" - - struct Summary: Sendable, Equatable { - var blockedCount: Int - var workingCount: Int - var mostRecentBlockedWorkspaceTitle: String? - } - - private static let container = CKContainer(identifier: containerIdentifier) - - /// Whether this iPhone is signed into iCloud at all. Does **not** detect a mismatched - /// Apple ID between this phone and the paired Mac -- CloudKit exposes no API for that, so a - /// wrong-account pairing still reports `.available` here and silently receives nothing. - /// The UI must say this explicitly (see `PairConnectView`) rather than imply this check is - /// a full guarantee. - static func accountStatus() async -> CKAccountStatus { - (try? await container.accountStatus()) ?? .couldNotDetermine - } - - /// Reconciles the desired subscription against the private database. A local flag cannot - /// represent server truth: the user may switch iCloud accounts or delete subscriptions - /// remotely while the app remains installed. - static func ensureSubscription() async { - let database = container.privateCloudDatabase - do { - let existing = try await database.subscription(for: subscriptionID) - if subscriptionMatchesDesiredState(existing) { return } - try await deleteSubscription(from: database) - } catch let error as CKError where error.code == .unknownItem { - // Missing is the normal first-run/server-deletion path; create below. - } catch { - NSLog("CloudKitPush: subscription reconciliation failed: %@", "\(error)") - return - } - - do { - try await saveSubscription(to: database) - } catch { - // No local success bit is recorded. Every foreground/connect retries. - NSLog("CloudKitPush: subscription save failed: %@", "\(error)") - } - } - - private static var desiredOptions: CKQuerySubscription.Options { - [.firesOnRecordCreation, .firesOnRecordUpdate] - } - - private static func makeDesiredSubscription() -> CKQuerySubscription { - let subscription = CKQuerySubscription( - recordType: recordType, - predicate: NSPredicate(value: true), - subscriptionID: subscriptionID, - options: desiredOptions - ) - - let info = CKSubscription.NotificationInfo() - // Generic on purpose -- workspace names must never transit Apple's push payload, only - // the record inside the user's own private database. A visible alertBody (with no - // sound) promotes delivery to the reliable high-priority channel and shows a - // lock-screen line without buzzing. - info.alertBody = String(localized: "cloudKit.push.alertBody", defaultValue: "An agent needs you") - info.soundName = nil - // The same delivery also wakes the app in the background so it can refresh the Live - // Activity locally -- see `AppDelegate.didReceiveRemoteNotification`. - info.shouldSendContentAvailable = true - subscription.notificationInfo = info - return subscription - } - - private static func subscriptionMatchesDesiredState(_ subscription: CKSubscription) -> Bool { - guard let query = subscription as? CKQuerySubscription, - query.recordType == recordType, - query.querySubscriptionOptions == desiredOptions, - let info = query.notificationInfo - else { - return false - } - return info.alertBody == String( - localized: "cloudKit.push.alertBody", - defaultValue: "An agent needs you" - ) - && info.soundName == nil - && info.shouldSendContentAvailable - } - - private static func deleteSubscription(from database: CKDatabase) async throws { - let result = try await database.modifySubscriptions( - saving: [], - deleting: [subscriptionID] - ) - guard let deletion = result.deleteResults[subscriptionID] else { - throw CloudKitPushError.missingModificationResult - } - try deletion.get() - } - - private static func saveSubscription(to database: CKDatabase) async throws { - let result = try await database.modifySubscriptions( - saving: [makeDesiredSubscription()], - deleting: [] - ) - guard let save = result.saveResults[subscriptionID] else { - throw CloudKitPushError.missingModificationResult - } - _ = try save.get() - } - - /// Fetches the current summary record. Returns `nil` if the record doesn't exist yet (the - /// Mac hasn't written anything), the account is unavailable, or the fetch failed -- - /// callers should treat all three identically: no-op, keep whatever local state exists. - static func fetchSummary() async -> Summary? { - let recordID = CKRecord.ID(recordName: recordName) - do { - let record = try await container.privateCloudDatabase.record(for: recordID) - return Summary( - blockedCount: record["blockedCount"] as? Int ?? 0, - workingCount: record["workingCount"] as? Int ?? 0, - mostRecentBlockedWorkspaceTitle: record["mostRecentBlockedWorkspaceTitle"] as? String - ) - } catch { - NSLog("CloudKitPush: fetch failed: %@", "\(error)") - return nil - } - } -} - -private enum CloudKitPushError: Error { - case missingModificationResult -} diff --git a/ios/ProgramaSpike/ProgramaSpike/ContentView.swift b/ios/ProgramaSpike/ProgramaSpike/ContentView.swift deleted file mode 100644 index cbf49964..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/ContentView.swift +++ /dev/null @@ -1,18 +0,0 @@ -import SwiftUI - -struct ContentView: View { - @State private var store = AppStore() - - var body: some View { - switch store.stage { - case .pairing: - PairConnectView(store: store) - case .workspaces: - WorkspaceListView(store: store) - } - } -} - -#Preview { - ContentView() -} diff --git a/ios/ProgramaSpike/ProgramaSpike/KeychainStore.swift b/ios/ProgramaSpike/ProgramaSpike/KeychainStore.swift deleted file mode 100644 index a90c99ab..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/KeychainStore.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation -import Security - -protocol KeychainAccess { - func data(service: String, account: String) throws -> Data? - func set(_ data: Data, service: String, account: String) throws - func delete(service: String, account: String) throws -} - -enum CredentialStoreError: Error, CustomStringConvertible { - case keychain(OSStatus) - case verificationFailed - case randomGeneration(OSStatus) - - var description: String { - switch self { - case let .keychain(status): - return SecCopyErrorMessageString(status, nil) as String? - ?? String.localizedStringWithFormat( - String(localized: "credential.error.keychain", defaultValue: "Keychain error %d"), - status - ) - case .verificationFailed: - return String( - localized: "credential.error.verificationFailed", - defaultValue: "Keychain verification failed" - ) - case let .randomGeneration(status): - return String.localizedStringWithFormat( - String( - localized: "credential.error.randomGenerationFailed", - defaultValue: "Secure random generation failed (%d)" - ), - status - ) - } - } -} - -struct SystemKeychainAccess: KeychainAccess { - func data(service: String, account: String) throws -> Data? { - var query = baseQuery(service: service, account: account) - query[kSecMatchLimit as String] = kSecMatchLimitOne - query[kSecReturnData as String] = true - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - switch status { - case errSecSuccess: - guard let data = result as? Data else { - throw CredentialStoreError.verificationFailed - } - return data - case errSecItemNotFound: - return nil - default: - throw CredentialStoreError.keychain(status) - } - } - - func set(_ data: Data, service: String, account: String) throws { - let query = baseQuery(service: service, account: account) - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - - let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) - if updateStatus == errSecSuccess { return } - guard updateStatus == errSecItemNotFound else { - throw CredentialStoreError.keychain(updateStatus) - } - - var item = query - attributes.forEach { item[$0.key] = $0.value } - let addStatus = SecItemAdd(item as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw CredentialStoreError.keychain(addStatus) - } - } - - func delete(service: String, account: String) throws { - let status = SecItemDelete(baseQuery(service: service, account: account) as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw CredentialStoreError.keychain(status) - } - } - - private func baseQuery(service: String, account: String) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrSynchronizable as String: kCFBooleanFalse as Any, - ] - } -} - -enum ProgramaCredentialKeychain { - static let service = "com.darkroom.programa.spike.credentials" - static let irohSecretAccount = "iroh-secret-key" - static let pairingTicketAccount = "pairing-ticket" -} diff --git a/ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift b/ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift deleted file mode 100644 index b860eb7f..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/LiveActivityCloudKitBridge.swift +++ /dev/null @@ -1,37 +0,0 @@ -import ActivityKit -import Foundation -import UIKit - -/// Background-wake half of M3's push path. Deliberately independent of `AppStore`: a silent -/// (`content-available`) CloudKit push can launch this app fully in the background before any -/// SwiftUI scene exists, and `AppStore` is only a `@State` owned by `ContentView` -- it may not -/// exist yet when `AppDelegate.didReceiveRemoteNotification` fires. This type re-resolves any -/// already-running Live Activity via `Activity<AgentActivityAttributes>.activities` instead, -/// exactly like `AppStore`'s own `nonisolated static` Live Activity helpers do (see that file's -/// doc comment on why `Activity` must be resolved locally rather than captured across an -/// isolation boundary). -/// -/// Only ever *updates* an existing Live Activity -- never starts a new one. Starting one is -/// `AppStore.ensureLiveActivityStarted()`'s job, which requires a live bridge-connected session -/// this path does not have. -enum LiveActivityCloudKitBridge { - private static let staleInterval: TimeInterval = 5 * 60 - - @discardableResult - static func reconcile() async -> UIBackgroundFetchResult { - guard let summary = await CloudKitPush.fetchSummary() else { return .failed } - guard let activity = Activity<AgentActivityAttributes>.activities.first else { return .noData } - - let newState = AgentActivityAttributes.ContentState( - blockedCount: summary.blockedCount, - workingCount: summary.workingCount, - headlineWorkspace: summary.blockedCount > 0 ? summary.mostRecentBlockedWorkspaceTitle : nil - ) - guard newState != activity.content.state else { return .noData } - - await activity.update( - ActivityContent(state: newState, staleDate: Date().addingTimeInterval(staleInterval)) - ) - return .newData - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/Models.swift b/ios/ProgramaSpike/ProgramaSpike/Models.swift deleted file mode 100644 index 72b4478f..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/Models.swift +++ /dev/null @@ -1,62 +0,0 @@ -import SwiftUI - -/// The "worst-of" badge for a workspace or a single surface. Ordering -/// (`blocked` > `working` > `idle`) is the whole point of the glance screen: -/// blocked workspaces sort to the top. -enum AgentBadge: Int, Comparable, Sendable { - case idle = 0 - case working = 1 - case blocked = 2 - - static func < (lhs: AgentBadge, rhs: AgentBadge) -> Bool { - lhs.rawValue < rhs.rawValue - } - - var symbolName: String { - switch self { - case .blocked: "exclamationmark.octagon.fill" - case .working: "bolt.fill" - case .idle: "moon.zzz.fill" - } - } - - var label: String { - switch self { - case .blocked: String(localized: "agentBadge.blocked", defaultValue: "Blocked") - case .working: String(localized: "agentBadge.working", defaultValue: "Working") - case .idle: String(localized: "agentBadge.idle", defaultValue: "Idle") - } - } - - /// Semantic colors (not literals) so this reads correctly in Dark Mode. - var tint: Color { - switch self { - case .blocked: .red - case .working: .blue - case .idle: .secondary - } - } -} - -struct WorkspaceRow: Identifiable, Equatable, Sendable { - let id: String - var title: String - var selected: Bool - var index: Int -} - -struct SurfaceRow: Identifiable, Equatable, Sendable { - let id: String - var title: String - var focused: Bool - var agentState: AgentState? - var agentStateSource: AgentStateSource? - - var badge: AgentBadge { - switch agentState { - case .blocked: .blocked - case .working: .working - case .idle, .none: .idle - } - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift b/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift deleted file mode 100644 index 30cd9223..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/PairConnectView.swift +++ /dev/null @@ -1,136 +0,0 @@ -import SwiftUI - -/// Screen 1: scan or paste the pairing code shown on Programa's Mac -/// Settings ▸ Phone screen, then connect. -/// -/// There is deliberately one way in. Separate ticket and token fields used to -/// sit below this as a legacy fallback, with their own Connect button, so the -/// screen offered three entry points and two buttons for a single action. The -/// Mac shows the same combined code it encodes in the QR, and pasting that is -/// the fallback for anyone who cannot scan. -struct PairConnectView: View { - @Bindable var store: AppStore - - @State private var showScanner = false - @State private var pairingCodeDraft = "" - @State private var pairingCodeError: String? - - var body: some View { - NavigationStack { - Form { - Section(String(localized: "pairing.connect.section.code", defaultValue: "Pairing code")) { - Button { - showScanner = true - } label: { - Label( - String(localized: "pairing.connect.scanButton", defaultValue: "Scan QR Code"), - systemImage: "qrcode.viewfinder" - ) - } - - TextField( - String( - localized: "pairing.connect.codeField.placeholder", - defaultValue: "Or paste the code from Programa ▸ Settings ▸ Phone" - ), - text: $pairingCodeDraft, - axis: .vertical - ) - .lineLimit(1 ... 4) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Button(String(localized: "pairing.connect.useCodeButton", defaultValue: "Connect")) { - applyPairingCodeDraft() - } - .disabled( - pairingCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || store.isConnecting - ) - - if let pairingCodeError { - Text(pairingCodeError) - .font(.footnote) - .foregroundStyle(.red) - } - } - - Section(String(localized: "pairing.connect.section.status", defaultValue: "Status")) { - LabeledContent(String(localized: "State", defaultValue: "State"), value: store.connectionBanner.label) - // The observed network path stays visible even on this - // screen — it remains diagnostically useful. - LabeledContent( - String(localized: "pairing.connect.networkPath.label", defaultValue: "Network path"), - value: store.observedPathDescription - ) - if let lastSyncError = store.lastSyncError { - Text(lastSyncError) - .foregroundStyle(.red) - .font(.footnote) - } - // Shown before pairing too: "which build am I on" is most - // often asked when the phone will not connect at all. - AppVersionRow() - } - - Section(String(localized: "notifications.title", defaultValue: "Notifications")) { - if let message = store.iCloudStatusMessage { - Text(message) - .font(.footnote) - .foregroundStyle(.orange) - } else { - Text(String(localized: "pairing.connect.icloud.signedIn", defaultValue: "iCloud is signed in on this iPhone.")) - .font(.footnote) - .foregroundStyle(.secondary) - } - // CloudKit has no API to detect this, so the app cannot warn about it - // directly -- it can only ever report "signed in" or "not signed in" on - // this device. A mismatch delivers nothing and raises no error. - Text( - String( - localized: "pairing.connect.icloud.accountMismatchNotice", - defaultValue: "This iPhone and your Mac must be signed into the same iCloud account for background notifications to arrive. Programa can't detect a mismatch — check the Apple ID on both devices if notifications never show up." - ) - ) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .navigationTitle(String(localized: "pairing.connect.title", defaultValue: "Connect to Programa")) - .sheet(isPresented: $showScanner) { - QRScannerView { code in - handleScannedCode(code) - } - } - } - } - - private func applyPairingCodeDraft() { - let trimmed = pairingCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard store.applyPairingCode(trimmed) else { - pairingCodeError = String( - localized: "pairing.connect.error.invalidPastedCode", - defaultValue: "That doesn't look like a Programa pairing code. Scan the QR code, or paste the full code shown below it on your Mac." - ) - return - } - pairingCodeError = nil - Task { await store.connectManually() } - } - - private func handleScannedCode(_ code: String) { - guard store.applyPairingCode(code) else { - pairingCodeError = String( - localized: "pairing.connect.error.invalidScannedCode", - defaultValue: "That QR code wasn't a valid Programa pairing code." - ) - return - } - pairingCodeError = nil - Task { await store.connectManually() } - } -} - -#Preview { - PairConnectView(store: AppStore()) -} diff --git a/ios/ProgramaSpike/ProgramaSpike/PairingCode.swift b/ios/ProgramaSpike/ProgramaSpike/PairingCode.swift deleted file mode 100644 index 023262f7..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/PairingCode.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -/// Mirrors `Sources/MobileBridge/MobileBridgePairingCode.swift` on the macOS -/// app. Kept in sync by hand -- this iOS target and the macOS app share no -/// module, so there is no compiler-enforced tie between the two copies. Any -/// format change here must be mirrored there. -enum PairingCode { - static let scheme = "programa-pair" - - /// Must match `MobileBridgePairingCode.currentVersion` on the Mac. This app - /// ships through TestFlight and so lags the Mac app: if a newer Mac starts - /// emitting a version this build does not know, rejecting is correct — the - /// alternative is reading `t`/`k` out of a format that may have moved. - static let currentVersion = "1" - - struct Parsed { - let ticket: String - let token: String - } - - /// Parses a combined `programa-pair://pair?v=1&t=<ticket>&k=<token>` - /// code (scanned or pasted) back into its ticket/token. Returns `nil` - /// for anything that isn't a well-formed code with both `t` and `k` - /// present -- callers should fall back to treating the input as a bare - /// ticket in that case. - static func parse(_ string: String) -> Parsed? { - let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) - guard let components = URLComponents(string: trimmed), - components.scheme?.lowercased() == scheme - else { return nil } - guard let items = components.queryItems else { return nil } - guard items.first(where: { $0.name == "v" })?.value == currentVersion else { return nil } - guard - let ticket = items.first(where: { $0.name == "t" })?.value, !ticket.isEmpty, - let token = items.first(where: { $0.name == "k" })?.value, !token.isEmpty - else { return nil } - return Parsed(ticket: ticket, token: token) - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/PairingStore.swift b/ios/ProgramaSpike/ProgramaSpike/PairingStore.swift deleted file mode 100644 index 110f2803..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/PairingStore.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -/// Persists the pairing ticket in the device-only Keychain so relaunching the app -/// reconnects without retyping it. The pairing token is intentionally never -/// persisted here — it is single-use and only needed the first time a -/// device pairs; every reconnect after that sends no pair line at all (the -/// bridge's allowlist already has this device's iroh EndpointID). -enum PairingStore { - private static let legacyTicketKey = "programa.spike.pairingTicket" - - static func loadTicket( - keychain: any KeychainAccess = SystemKeychainAccess(), - defaults: UserDefaults = .standard - ) throws -> String? { - if let stored = try keychain.data( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.pairingTicketAccount - ) { - guard let ticket = String(data: stored, encoding: .utf8), - !ticket.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - try keychain.delete( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.pairingTicketAccount - ) - return nil - } - return ticket - } - - guard let legacy = defaults.string(forKey: legacyTicketKey) else { return nil } - guard !legacy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - defaults.removeObject(forKey: legacyTicketKey) - return nil - } - try saveTicket(legacy, keychain: keychain) - defaults.removeObject(forKey: legacyTicketKey) - return legacy - } - - static func saveTicket( - _ ticket: String, - keychain: any KeychainAccess = SystemKeychainAccess() - ) throws { - let data = Data(ticket.utf8) - guard !data.isEmpty else { throw CredentialStoreError.verificationFailed } - try keychain.set( - data, - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.pairingTicketAccount - ) - guard try keychain.data( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.pairingTicketAccount - ) == data else { - throw CredentialStoreError.verificationFailed - } - } - - private static let macNameKey = "pairedMacName" - - /// Persisted because the Mac only sends its name on the pairing frame; - /// every later connection is a trusted reconnect that sends none. - static func loadMacName() -> String? { - UserDefaults.standard.string(forKey: macNameKey) - } - - static func saveMacName(_ name: String) { - UserDefaults.standard.set(name, forKey: macNameKey) - } - - static func clearTicket( - keychain: any KeychainAccess = SystemKeychainAccess(), - defaults: UserDefaults = .standard - ) throws { - try keychain.delete( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.pairingTicketAccount - ) - defaults.removeObject(forKey: legacyTicketKey) - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/PathClassifier.swift b/ios/ProgramaSpike/ProgramaSpike/PathClassifier.swift deleted file mode 100644 index 816ccc34..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/PathClassifier.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Darwin -import Foundation -import IrohLib - -/// The mobile app's user-facing classification of iroh's selected path. -enum ObservedPath: CustomStringConvertible, Equatable, Sendable { - case direct - case privateNetwork - case relay(url: String) - case unavailable - - var description: String { - switch self { - case .direct: String(localized: "connection.path.direct", defaultValue: "direct") - case .privateNetwork: String(localized: "connection.path.privateNetwork", defaultValue: "private network") - case let .relay(url): - String.localizedStringWithFormat( - String(localized: "connection.path.relay", defaultValue: "relay (%@)"), - url - ) - case .unavailable: String(localized: "connection.path.unavailable", defaultValue: "unavailable") - } - } -} - -enum PathClassifier { - static func classify(_ snapshots: [PathSnapshot]) -> ObservedPath { - guard let selected = snapshots.first(where: \.isSelected) else { - return .unavailable - } - if selected.isRelay { - return .relay(url: selected.remoteAddr) - } - if selected.isIp { - return isPrivateAddress(selected.remoteAddr) ? .privateNetwork : .direct - } - return .unavailable - } - - static func waitForSelectedPath( - connection: Connection, - timeout: Duration - ) async -> ObservedPath { - await waitForSelectedPath( - timeout: timeout, - now: { ContinuousClock.now }, - selectedPath: { classify(connection.paths()) }, - sleep: { try await Task.sleep(for: $0) } - ) - } - - static func waitForSelectedPath( - timeout: Duration, - now: () -> ContinuousClock.Instant, - selectedPath: () -> ObservedPath, - sleep: (Duration) async throws -> Void - ) async -> ObservedPath { - let deadline = now().advanced(by: timeout) - var lastRelay: ObservedPath? - var lastObserved = ObservedPath.unavailable - - while now() < deadline { - guard !Task.isCancelled else { return lastRelay ?? lastObserved } - let classified = selectedPath() - lastObserved = classified - switch classified { - case .direct, .privateNetwork: - return classified - case .relay: - lastRelay = classified - case .unavailable: - break - } - do { - try await sleep(.milliseconds(100)) - } catch { - // Task.sleep throws on cancellation. Returning the last useful observation - // preserves this non-throwing connection-status contract without hot-spinning - // until the original deadline. - return lastRelay ?? lastObserved - } - } - - return lastRelay ?? selectedPath() - } - - private static func isPrivateAddress(_ socketAddress: String) -> Bool { - guard let host = host(from: socketAddress) else { return false } - let literal = host.split(separator: "%", maxSplits: 1).first.map(String.init) ?? host - - var ipv4 = in_addr() - if literal.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { - let address = UInt32(bigEndian: ipv4.s_addr) - let first = UInt8(truncatingIfNeeded: address >> 24) - let second = UInt8(truncatingIfNeeded: address >> 16) - return first == 10 - || first == 127 - || (first == 100 && (64 ... 127).contains(second)) - || (first == 169 && second == 254) - || (first == 172 && (16 ... 31).contains(second)) - || (first == 192 && second == 168) - } - - var ipv6 = in6_addr() - if literal.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { - let bytes = withUnsafeBytes(of: &ipv6) { Array($0) } - let isUniqueLocal = bytes[0] & 0xFE == 0xFC - let isLinkLocal = bytes[0] == 0xFE && bytes[1] & 0xC0 == 0x80 - let isLoopback = bytes.dropLast().allSatisfy { $0 == 0 } && bytes.last == 1 - return isUniqueLocal || isLinkLocal || isLoopback - } - - return false - } - - private static func host(from socketAddress: String) -> String? { - if socketAddress.first == "[", - let closingBracket = socketAddress.firstIndex(of: "]") { - return String( - socketAddress[socketAddress.index(after: socketAddress.startIndex) ..< closingBracket] - ) - } - let colonCount = socketAddress.reduce(into: 0) { count, character in - if character == ":" { count += 1 } - } - if colonCount == 1, let colon = socketAddress.lastIndex(of: ":") { - return String(socketAddress[..<colon]) - } - return colonCount > 1 ? socketAddress : nil - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy b/ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy deleted file mode 100644 index 2713f106..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,35 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <!-- - Required since iOS 17: an app that calls a "required reason API" without - declaring why is rejected at upload. This app collects nothing and tracks - nobody -- it talks only to the user's own paired Mac over a direct - peer-to-peer connection, and to the user's own iCloud account. - --> - <key>NSPrivacyTracking</key> - <false/> - <key>NSPrivacyTrackingDomains</key> - <array/> - <key>NSPrivacyCollectedDataTypes</key> - <array/> - <key>NSPrivacyAccessedAPITypes</key> - <array> - <dict> - <!-- - UserDefaults stores the paired Mac's display name. Credentials live - in device-only Keychain items, and CloudKit subscription state is - reconciled with the server. The display name is this app's own state, - read back only by this app: reason CA92.1. - --> - <key>NSPrivacyAccessedAPIType</key> - <string>NSPrivacyAccessedAPICategoryUserDefaults</string> - <key>NSPrivacyAccessedAPITypeReasons</key> - <array> - <string>CA92.1</string> - </array> - </dict> - </array> -</dict> -</plist> diff --git a/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift b/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift deleted file mode 100644 index 0b291f7e..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/ProgramaSpikeApp.swift +++ /dev/null @@ -1,15 +0,0 @@ -import SwiftUI - -@main -struct ProgramaSpikeApp: App { - // M3: registers for remote notifications and handles the background-wake half of the - // CloudKit push path (`didReceiveRemoteNotification`) -- see `AppDelegate`'s doc comment - // for why that logic lives outside the SwiftUI `AppStore`. - @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift b/ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift deleted file mode 100644 index ed51c25e..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/QRScannerView.swift +++ /dev/null @@ -1,194 +0,0 @@ -import AVFoundation -import SwiftUI - -/// A sheet that scans a QR code with `AVCaptureMetadataOutput` and calls -/// `onCode` with the decoded string on the first successful scan. Uses -/// `AVCaptureMetadataOutput` rather than `DataScannerViewController` -- -/// simpler API, needs only the one `NSCameraUsageDescription` key, and -/// avoids `DataScannerViewController`'s own separate availability checks. -/// -/// Degrades gracefully rather than crashing when no camera is available -/// (the simulator, or a device whose camera failed to initialize), and -/// surfaces a clear message rather than a silent dead end when the user has -/// denied camera permission. -struct QRScannerView: View { - let onCode: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var authorizationStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video) - @State private var cameraUnavailable = false - - var body: some View { - NavigationStack { - content - .navigationTitle(String(localized: "pairing.scanner.title", defaultValue: "Scan QR Code")) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "common.cancel", defaultValue: "Cancel")) { dismiss() } - } - } - } - } - - @ViewBuilder - private var content: some View { - switch authorizationStatus { - case .authorized: - if cameraUnavailable { - unavailableView( - message: String( - localized: "pairing.scanner.unavailable.noCamera", - defaultValue: "No camera is available on this device. Paste the pairing code instead." - ) - ) - } else { - QRCaptureRepresentable( - onCode: { code in - onCode(code) - dismiss() - }, - onUnavailable: { cameraUnavailable = true } - ) - .ignoresSafeArea() - } - case .notDetermined: - ProgressView() - .task { await requestAccess() } - case .denied, .restricted: - unavailableView( - message: String( - localized: "pairing.scanner.unavailable.accessDenied", - defaultValue: "Camera access is off for Programa. Enable it in Settings ▸ Programa to scan the pairing code, or paste it instead." - ) - ) - @unknown default: - unavailableView( - message: String( - localized: "pairing.scanner.unavailable.generic", - defaultValue: "Camera unavailable. Paste the pairing code instead." - ) - ) - } - } - - private func requestAccess() async { - let granted = await AVCaptureDevice.requestAccess(for: .video) - authorizationStatus = granted ? .authorized : .denied - } - - @ViewBuilder - private func unavailableView(message: String) -> some View { - VStack(spacing: 12) { - Image(systemName: "camera.fill") - .font(.system(size: 40)) - .foregroundStyle(.secondary) - Text(message) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal, 24) - } - } -} - -/// Wraps a bare `AVCaptureSession` in a `UIViewControllerRepresentable`. -private struct QRCaptureRepresentable: UIViewControllerRepresentable { - let onCode: (String) -> Void - let onUnavailable: () -> Void - - func makeUIViewController(context: Context) -> QRScannerViewController { - let controller = QRScannerViewController() - controller.onCode = onCode - controller.onUnavailable = onUnavailable - return controller - } - - func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} -} - -// `@preconcurrency` on the delegate conformance: the callback is delivered -// on `.main` (set explicitly below via `setMetadataObjectsDelegate(_:queue:)`), -// so it is genuinely main-actor-safe even though the protocol itself -// predates Swift concurrency and isn't annotated as such. -private final class QRScannerViewController: UIViewController, @preconcurrency AVCaptureMetadataOutputObjectsDelegate { - var onCode: ((String) -> Void)? - var onUnavailable: (() -> Void)? - - private let session = AVCaptureSession() - private var previewLayer: AVCaptureVideoPreviewLayer? - private var didEmit = false - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - configureSession() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.frame = view.bounds - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - guard previewLayer != nil, !session.isRunning else { return } - DispatchQueue.global(qos: .userInitiated).async { [session] in - session.startRunning() - } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - guard session.isRunning else { return } - DispatchQueue.global(qos: .userInitiated).async { [session] in - session.stopRunning() - } - } - - /// No camera hardware (the simulator) or a camera that fails to open - /// both land here -- `onUnavailable()` is the one path back to a - /// non-crashing UI state either way. - private func configureSession() { - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) - else { - onUnavailable?() - return - } - session.addInput(input) - - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { - onUnavailable?() - return - } - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) - output.metadataObjectTypes = [.qr] - - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - layer.frame = view.bounds - view.layer.addSublayer(layer) - previewLayer = layer - } - - func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection - ) { - guard !didEmit else { return } - guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, - object.type == .qr, - let value = object.stringValue - else { return } - didEmit = true - session.stopRunning() - onCode?(value) - } -} - -#Preview { - QRScannerView(onCode: { _ in }) -} diff --git a/ios/ProgramaSpike/ProgramaSpike/SecretKeyStore.swift b/ios/ProgramaSpike/ProgramaSpike/SecretKeyStore.swift deleted file mode 100644 index e8eb9d4b..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/SecretKeyStore.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation -import Security - -/// Persists a stable 32-byte Iroh secret in the device-only Keychain so this -/// device's node identity survives relaunch without syncing or entering a backup. -enum SecretKeyStore { - private static let legacyDefaultsKey = "programa.spike.secretKey" - - static func loadOrCreate( - keychain: any KeychainAccess = SystemKeychainAccess(), - defaults: UserDefaults = .standard - ) throws -> Data { - if let stored = try keychain.data( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.irohSecretAccount - ) { - if stored.count == 32 { return stored } - try keychain.delete( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.irohSecretAccount - ) - } - - let legacy = defaults.data(forKey: legacyDefaultsKey) - let secret: Data - if let legacy, legacy.count == 32 { - secret = legacy - } else { - secret = try generateSecret() - } - try persistAndVerify(secret, keychain: keychain) - if legacy != nil { - defaults.removeObject(forKey: legacyDefaultsKey) - } - return secret - } - - private static func generateSecret() throws -> Data { - var bytes = [UInt8](repeating: 0, count: 32) - let status = bytes.withUnsafeMutableBytes { buffer in - SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!) - } - guard status == errSecSuccess else { - throw CredentialStoreError.randomGeneration(status) - } - return Data(bytes) - } - - private static func persistAndVerify( - _ secret: Data, - keychain: any KeychainAccess - ) throws { - try keychain.set( - secret, - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.irohSecretAccount - ) - guard try keychain.data( - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.irohSecretAccount - ) == secret else { - throw CredentialStoreError.verificationFailed - } - } -} diff --git a/ios/ProgramaSpike/ProgramaSpike/Shared/AgentActivityAttributes.swift b/ios/ProgramaSpike/ProgramaSpike/Shared/AgentActivityAttributes.swift deleted file mode 100644 index d4834be4..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/Shared/AgentActivityAttributes.swift +++ /dev/null @@ -1,17 +0,0 @@ -import ActivityKit - -/// Shared between the app target (`ProgramaSpike`) and the widget extension -/// target (`ProgramaSpikeWidgets`) -- this file is listed in both targets' -/// `sources` in `project.yml`. `ContentState` is serialized on every -/// `Activity.update`, so keep it small. -struct AgentActivityAttributes: ActivityAttributes { - struct ContentState: Codable, Hashable, Sendable { - var blockedCount: Int - var workingCount: Int - /// Title of the workspace that most recently became blocked, if any. - var headlineWorkspace: String? - } - - /// Set once when the activity starts; the Mac this phone is paired to. - var macName: String -} diff --git a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings b/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings deleted file mode 100644 index 66a49d05..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/Shared/Localizable.xcstrings +++ /dev/null @@ -1,1281 +0,0 @@ -{ - "sourceLanguage": "en", - "strings": { - "State": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "State" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "状態" - } - } - } - }, - "about.version.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Version" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バージョン" - } - } - } - }, - "about.version.unknown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "unknown" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "不明" - } - } - } - }, - "agentBadge.blocked": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Blocked" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブロック中" - } - } - } - }, - "agentBadge.idle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Idle" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "待機中" - } - } - } - }, - "agentBadge.working": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Working" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "作業中" - } - } - } - }, - "agentDetail.prompt.placeholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Prompt text" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロンプトのテキスト" - } - } - } - }, - "agentDetail.section.surfaces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Surfaces" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "サーフェス" - } - } - } - }, - "agentDetail.section.unblock": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unblock it" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブロックを解除" - } - } - } - }, - "agentDetail.send.button": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Send" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "送信" - } - } - } - }, - "agentDetail.send.failed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "失敗しました:%@" - } - } - } - }, - "agentDetail.send.success": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sent." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "送信しました。" - } - } - } - }, - "agentDetail.surfaces.empty": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No surfaces in this workspace." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このワークスペースにはサーフェスがありません。" - } - } - } - }, - "bridge.error.connectionClosed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "connection closed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続が閉じられました" - } - } - } - }, - "bridge.error.connectionLost": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "connection lost: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続が失われました:%@" - } - } - } - }, - "bridge.error.deviceIdentityFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "could not load device identity" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デバイスIDを読み込めませんでした" - } - } - } - }, - "bridge.error.encodingFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "failed to encode request" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リクエストのエンコードに失敗しました" - } - } - } - }, - "bridge.error.invalidTicket": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "invalid pairing ticket" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "無効なペア設定チケットです" - } - } - } - }, - "bridge.error.malformedResponse": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "malformed response from bridge" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブリッジからの応答が不正です" - } - } - } - }, - "bridge.error.notAdmitted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "not admitted by the Mac: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Macに承認されませんでした:%@" - } - } - } - }, - "bridge.error.notConnected": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "not connected to the bridge" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブリッジに接続されていません" - } - } - } - }, - "bridge.error.setupFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "connection setup failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続のセットアップに失敗しました" - } - } - } - }, - "bridge.error.ticketParseFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "could not parse the pairing ticket" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定チケットを解析できませんでした" - } - } - } - }, - "bridge.error.timedOut": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "the Mac did not respond in time" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Macが時間内に応答しませんでした" - } - } - } - }, - "bridge.error.unreachable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "could not reach that Mac — is Programa running with Settings ▸ Phone turned on?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "そのMacに接続できませんでした — Programaが起動していて「設定 ▸ 電話」がオンになっていますか?" - } - } - } - }, - "cloudKit.push.alertBody": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "An agent needs you" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントの対応が必要です" - } - } - } - }, - "common.cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - } - } - }, - "connection.path.direct": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "direct" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "直接接続" - } - } - } - }, - "connection.path.privateNetwork": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "private network" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プライベートネットワーク" - } - } - } - }, - "connection.path.relay": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "relay (%@)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレー(%@)" - } - } - } - }, - "connection.path.unavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "unavailable" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "利用不可" - } - } - } - }, - "connection.path.unknown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "unknown" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "不明" - } - } - } - }, - "icloud.status.couldNotDetermine": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not check this iPhone's iCloud status." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このiPhoneのiCloudステータスを確認できませんでした。" - } - } - } - }, - "icloud.status.noAccount": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in to iCloud on this iPhone (Settings > [your name]) to get notified when an agent needs you while Programa is in the background." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Programaがバックグラウンドで実行されている間にエージェントが対応を必要とした際に通知を受け取るには、このiPhoneでiCloudにサインインしてください(設定 > [あなたの名前])。" - } - } - } - }, - "icloud.status.restricted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iCloud is restricted on this iPhone, so background notifications won't work." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このiPhoneではiCloudが制限されているため、バックグラウンド通知は機能しません。" - } - } - } - }, - "icloud.status.temporarilyUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iCloud is temporarily unavailable, so background notifications may be delayed." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "iCloudが一時的に利用できないため、バックグラウンド通知が遅れる場合があります。" - } - } - } - }, - "liveActivity.error.disabled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Live Activities are disabled; agent status won't appear on the Lock Screen." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ライブアクティビティが無効になっているため、エージェントの状態はロック画面に表示されません。" - } - } - } - }, - "liveActivity.error.startFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not start Live Activity: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ライブアクティビティを開始できませんでした:%@" - } - } - } - }, - "liveActivity.macName": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "your Mac" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "あなたのMac" - } - } - } - }, - "live_activity.headline.allClear": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "All clear" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "問題なし" - } - } - } - }, - "live_activity.headline.blocked.one": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "1 agent needs you" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1件のエージェントが対応を待っています" - } - } - } - }, - "live_activity.headline.blocked.other": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld agents need you" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld件のエージェントが対応を待っています" - } - } - } - }, - "live_activity.headline.working.one": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "1 working" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1件作業中" - } - } - } - }, - "live_activity.headline.working.other": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld working" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld件作業中" - } - } - } - }, - "credential.error.keychain": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keychain error %d" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キーチェーンエラー %d" - } - } - } - }, - "credential.error.randomGenerationFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Secure random generation failed (%d)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "安全な乱数の生成に失敗しました(%d)" - } - } - } - }, - "credential.error.verificationFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Keychain verification failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キーチェーンの検証に失敗しました" - } - } - } - }, - "notifications.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notifications" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "通知" - } - } - } - }, - "pairing.connect.codeField.placeholder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Or paste the code from Programa ▸ Settings ▸ Phone" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "またはProgramaの「設定 ▸ 電話」にあるコードを貼り付け" - } - } - } - }, - "pairing.connect.error.invalidPastedCode": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "That doesn't look like a Programa pairing code. Scan the QR code, or paste the full code shown below it on your Mac." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Programaのペア設定コードではないようです。QRコードをスキャンするか、Macでその下に表示されるコード全体を貼り付けてください。" - } - } - } - }, - "pairing.connect.error.invalidScannedCode": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "That QR code wasn't a valid Programa pairing code." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "そのQRコードは有効なProgramaのペア設定コードではありませんでした。" - } - } - } - }, - "pairing.connect.icloud.accountMismatchNotice": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This iPhone and your Mac must be signed into the same iCloud account for background notifications to arrive. Programa can't detect a mismatch — check the Apple ID on both devices if notifications never show up." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バックグラウンド通知を受け取るには、このiPhoneとMacが同じiCloudアカウントでサインインしている必要があります。Programaはこの不一致を検出できないため、通知が届かない場合は両方のデバイスのApple IDを確認してください。" - } - } - } - }, - "pairing.connect.icloud.signedIn": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iCloud is signed in on this iPhone." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このiPhoneはiCloudにサインイン済みです。" - } - } - } - }, - "pairing.connect.networkPath.label": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Network path" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネットワーク経路" - } - } - } - }, - "pairing.connect.scanButton": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Scan QR Code" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "QRコードをスキャン" - } - } - } - }, - "pairing.connect.section.code": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Pairing code" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定コード" - } - } - } - }, - "pairing.connect.section.status": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Status" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ステータス" - } - } - } - }, - "pairing.connect.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connect to Programa" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Programaに接続" - } - } - } - }, - "pairing.connect.useCodeButton": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connect" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続" - } - } - } - }, - "pairing.error.credentialLoadFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not load saved pairing credentials: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保存済みのペア設定認証情報を読み込めませんでした:%@" - } - } - } - }, - "pairing.error.credentialSaveFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not save pairing credentials: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定認証情報を保存できませんでした:%@" - } - } - } - }, - "pairing.scanner.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Scan QR Code" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "QRコードをスキャン" - } - } - } - }, - "pairing.scanner.unavailable.accessDenied": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Camera access is off for Programa. Enable it in Settings ▸ Programa to scan the pairing code, or paste it instead." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Programaのカメラアクセスがオフになっています。ペア設定コードをスキャンするには設定 ▸ Programaで有効にするか、代わりに貼り付けてください。" - } - } - } - }, - "pairing.scanner.unavailable.generic": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Camera unavailable. Paste the pairing code instead." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "カメラを利用できません。代わりにペア設定コードを貼り付けてください。" - } - } - } - }, - "pairing.scanner.unavailable.noCamera": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No camera is available on this device. Paste the pairing code instead." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このデバイスではカメラを使用できません。代わりにペア設定コードを貼り付けてください。" - } - } - } - }, - "remote.status.connected": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connected" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続済み" - } - } - } - }, - "remote.status.connecting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connecting…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続中…" - } - } - } - }, - "remote.status.disconnected": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not connected" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未接続" - } - } - } - }, - "remote.status.reconnecting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reconnecting…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再接続中…" - } - } - } - }, - "surface.title.fallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Surface" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "サーフェス" - } - } - } - }, - "workspace.displayName.fallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspace" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ワークスペース" - } - } - } - }, - "workspace.title.untitled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Untitled workspace" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "無題のワークスペース" - } - } - } - }, - "workspaceList.changePairing.button": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Change Pairing" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペア設定を変更" - } - } - } - }, - "workspaceList.empty": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No workspaces yet." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ワークスペースはまだありません。" - } - } - } - }, - "workspaceList.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Workspaces" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ワークスペース" - } - } - } - } - }, - "version": "1.0" -} diff --git a/ios/ProgramaSpike/ProgramaSpike/WireModels.swift b/ios/ProgramaSpike/ProgramaSpike/WireModels.swift deleted file mode 100644 index 2f57d8fe..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/WireModels.swift +++ /dev/null @@ -1,216 +0,0 @@ -import Foundation - -// MARK: - Agent state - -/// Exactly the three values the wire contract permits. `agent_state`/ -/// `agent_state_source` field names and values confirmed against -/// `Sources/TerminalController+Surface.swift` (`v2SurfaceList`) and -/// `tests_v2/test_agent_activity_state_socket.py`. -enum AgentState: String, Codable, Sendable { - case working - case blocked - case idle -} - -enum AgentStateSource: String, Codable, Sendable { - case hooks - case inferred -} - -// MARK: - Request params - -/// A minimal `Sendable`/`Codable` JSON value covering only the param shapes -/// our allowed methods actually need (plain strings for `workspace_id`/ -/// `surface_id`/`text`, a string array for `subscribe`'s `classes`). -/// Deliberately not a fully general `JSONValue` -- the wire contract only -/// calls for these two shapes from the client side. -enum RPCParam: Sendable, Encodable { - case string(String) - case stringArray([String]) - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case let .string(value): - try container.encode(value) - case let .stringArray(values): - try container.encode(values) - } - } -} - -// MARK: - Errors - -struct WireErrorPayload: Decodable, Sendable { - let code: String - let message: String? -} - -enum BridgeError: Error, CustomStringConvertible, Sendable { - case notConnected - case invalidTicket - case disconnected - case encodingFailed - case malformedResponse - case timedOut - case rpc(code: String, message: String?) - - var description: String { - switch self { - case .notConnected: String(localized: "bridge.error.notConnected", defaultValue: "not connected to the bridge") - case .timedOut: String(localized: "bridge.error.timedOut", defaultValue: "the Mac did not respond in time") - case .invalidTicket: String(localized: "bridge.error.ticketParseFailed", defaultValue: "could not parse the pairing ticket") - case .disconnected: String(localized: "bridge.error.connectionClosed", defaultValue: "connection closed") - case .encodingFailed: String(localized: "bridge.error.encodingFailed", defaultValue: "failed to encode request") - case .malformedResponse: String(localized: "bridge.error.malformedResponse", defaultValue: "malformed response from bridge") - // Out of scope (see issue #203 part-2 notes): this leaks a raw wire - // code (e.g. "not_paired") into UI text when `message` is nil. Not - // fixed here — flagged as a known wart, not a localization gap. - case let .rpc(code, message): message.map { "\(code): \($0)" } ?? code - } - } -} - -// MARK: - workspace.list -// Field names confirmed against Sources/TerminalController+Workspace.swift -// (v2WorkspaceList / v2WorkspaceSummaryPayload) and tests_v2/cmux.py's -// list_workspaces() helper. - -struct WireWorkspace: Decodable, Sendable { - let id: String - let title: String? - let selected: Bool? - let index: Int? -} - -struct WireWorkspaceListResult: Decodable, Sendable { - let workspaces: [WireWorkspace]? -} - -// MARK: - surface.list -// Field names confirmed against Sources/TerminalController+Surface.swift -// (v2SurfaceList) and tests_v2/test_agent_activity_state_socket.py / -// tests_v2/test_surface_list_custom_titles.py. Note: the real `surface.list` -// response carries `workspace_id` once at the top level (the call is scoped -// to one workspace), not per-surface as the wire-contract prose suggested -- -// the client attaches workspace_id itself from the request context instead -// of reading it off each surface row. - -struct WireSurface: Decodable, Sendable { - let id: String - let title: String? - let focused: Bool? - let agentState: AgentState? - let agentStateSource: AgentStateSource? - - enum CodingKeys: String, CodingKey { - case id, title, focused - case agentState = "agent_state" - case agentStateSource = "agent_state_source" - } -} - -struct WireSurfaceListResult: Decodable, Sendable { - let workspaceId: String? - let surfaces: [WireSurface]? - - enum CodingKeys: String, CodingKey { - case workspaceId = "workspace_id" - case surfaces - } -} - -// MARK: - subscribe -// Response shape per docs/v2-api-migration.md "Socket Event Subscriptions". - -struct WireSubscribeResult: Decodable, Sendable { - let subscriptionId: String? - let classes: [String]? - - enum CodingKeys: String, CodingKey { - case subscriptionId = "subscription_id" - case classes - } -} - -// MARK: - agent.prompt -// Response shape per docs/v2-api-migration.md "agent.prompt (#166)". - -struct WireAgentPromptResult: Decodable, Sendable { - let workspaceId: String? - let surfaceId: String? - let workingObserved: Bool? - let finalState: String? - let warning: String? - - enum CodingKeys: String, CodingKey { - case workspaceId = "workspace_id" - case surfaceId = "surface_id" - case workingObserved = "working_observed" - case finalState = "final_state" - case warning - } -} - -// MARK: - Event frames -// Shapes per docs/v2-api-migration.md "Event frames" / the task's wire -// contract. Event frames have an "event" key and no "id". - -struct WireAgentStateEvent: Decodable, Sendable { - let workspaceId: String - let surfaceId: String - let state: AgentState? - let source: AgentStateSource? - - enum CodingKeys: String, CodingKey { - case workspaceId = "workspace_id" - case surfaceId = "surface_id" - case state, source - } -} - -struct WireOutputEvent: Decodable, Sendable { - let workspaceId: String - let surfaceId: String - let text: String - - enum CodingKeys: String, CodingKey { - case workspaceId = "workspace_id" - case surfaceId = "surface_id" - case text - } -} - -struct WireWorkspaceLifecycleEvent: Decodable, Sendable { - let kind: String - let workspaceId: String - let title: String? - - enum CodingKeys: String, CodingKey { - case kind - case workspaceId = "workspace_id" - case title - } -} - -struct WireDroppedEvent: Decodable, Sendable { - let count: Int -} - -/// Sent by the bridge on every admission (pairing and trusted reconnect -/// alike), so the Mac's display name stays correct even if it is renamed. -struct WireBridgeHelloEvent: Decodable, Sendable { - let macName: String? - - private enum CodingKeys: String, CodingKey { - case macName = "mac_name" - } -} - -enum BridgeEvent: Sendable { - case bridgeHello(WireBridgeHelloEvent) - case agentState(WireAgentStateEvent) - case output(WireOutputEvent) - case workspaceLifecycle(WireWorkspaceLifecycleEvent) - case dropped(Int) -} diff --git a/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift b/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift deleted file mode 100644 index 2da59fd4..00000000 --- a/ios/ProgramaSpike/ProgramaSpike/WorkspaceListView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI - -/// Screen 2 — the glance screen: one row per workspace, worst-of state -/// badge, blocked workspaces sorted to the top, live updates from -/// `agent_state` events, pull-to-refresh triggers a full resync. -struct WorkspaceListView: View { - @Bindable var store: AppStore - - var body: some View { - NavigationStack { - List { - Section { - HStack { - Image(systemName: store.connectionBanner.symbolName) - Text(store.connectionBanner.label) - Spacer() - Text(store.observedPathDescription) - } - .font(.footnote) - .foregroundStyle(.secondary) - } - - Section(String(localized: "workspaceList.title", defaultValue: "Workspaces")) { - if store.sortedWorkspaces.isEmpty { - Text(String(localized: "workspaceList.empty", defaultValue: "No workspaces yet.")) - .foregroundStyle(.secondary) - } - ForEach(store.sortedWorkspaces) { workspace in - NavigationLink(value: workspace.id) { - WorkspaceRowView(workspace: workspace, badge: store.badge(for: workspace.id)) - } - } - } - - Section { - AppVersionRow() - } - } - .navigationTitle(String(localized: "workspaceList.title", defaultValue: "Workspaces")) - .navigationDestination(for: String.self) { workspaceID in - AgentDetailView(store: store, workspaceID: workspaceID) - } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "workspaceList.changePairing.button", defaultValue: "Change Pairing")) { store.returnToPairing() } - } - } - .refreshable { - await store.manualResync() - } - } - } -} - -private struct WorkspaceRowView: View { - let workspace: WorkspaceRow - let badge: AgentBadge - - var body: some View { - HStack { - Image(systemName: badge.symbolName) - .foregroundStyle(badge.tint) - .frame(width: 24) - VStack(alignment: .leading) { - Text(workspace.title) - .font(.body) - Text(badge.label) - .font(.caption) - .foregroundStyle(badge.tint) - } - Spacer() - } - } -} - -#Preview { - WorkspaceListView(store: AppStore()) -} diff --git a/ios/ProgramaSpike/ProgramaSpikeTests/BridgeConnectionDeadlineTests.swift b/ios/ProgramaSpike/ProgramaSpikeTests/BridgeConnectionDeadlineTests.swift deleted file mode 100644 index 2711a05f..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeTests/BridgeConnectionDeadlineTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation -import Testing -@testable import ProgramaSpike - -private actor SilentPairingPeer { - private var readContinuation: CheckedContinuation<Data?, Error>? - private var startWaiters: [CheckedContinuation<Void, Never>] = [] - private(set) var closeCount = 0 - - func read() async throws -> Data? { - return try await withCheckedThrowingContinuation { continuation in - readContinuation = continuation - for waiter in startWaiters { waiter.resume() } - startWaiters.removeAll() - } - } - - func waitUntilReadStarts() async { - if readContinuation != nil { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } - - func close() { - closeCount += 1 - readContinuation?.resume(throwing: BridgeError.disconnected) - readContinuation = nil - } -} - -@Test func initialPairingReplyTimeoutClosesSilentPeerWithoutPendingRequests() async { - let peer = SilentPairingPeer() - let connection = BridgeConnection() - let deadline = InitialPairingReplyDeadline() - let startedAt = ContinuousClock.now - - do { - _ = try await deadline.run( - timeout: .milliseconds(25), - read: { try await peer.read() }, - abort: { await peer.close() } - ) - Issue.record("silent pairing peer unexpectedly returned a reply") - } catch let error as BridgeError { - guard case .timedOut = error else { - Issue.record("unexpected bridge error: \(error)") - return - } - } catch { - Issue.record("unexpected error: \(error)") - } - - let closeCount = await peer.closeCount - let pendingRequestCount = await connection.pendingRequestCountForTesting() - #expect(ContinuousClock.now - startedAt < .seconds(1)) - #expect(closeCount == 1) - #expect(pendingRequestCount == 0) -} - -@Test func cancellingInitialPairingReplyClosesSilentPeerWithoutPendingRequests() async { - let peer = SilentPairingPeer() - let connection = BridgeConnection() - let deadline = InitialPairingReplyDeadline() - let task = Task { - try await deadline.run( - timeout: .seconds(5), - read: { try await peer.read() }, - abort: { await peer.close() } - ) - } - - await peer.waitUntilReadStarts() - task.cancel() - do { - _ = try await task.value - Issue.record("cancelled pairing read unexpectedly succeeded") - } catch is CancellationError { - // Expected. - } catch { - Issue.record("unexpected cancellation error: \(error)") - } - - let closeCount = await peer.closeCount - let pendingRequestCount = await connection.pendingRequestCountForTesting() - #expect(closeCount == 1) - #expect(pendingRequestCount == 0) -} diff --git a/ios/ProgramaSpike/ProgramaSpikeTests/CredentialStoreTests.swift b/ios/ProgramaSpike/ProgramaSpikeTests/CredentialStoreTests.swift deleted file mode 100644 index f50a08a8..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeTests/CredentialStoreTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -import Testing -@testable import ProgramaSpike - -private final class MemoryKeychain: KeychainAccess { - private(set) var values: [String: Data] = [:] - - func data(service: String, account: String) throws -> Data? { - values["\(service):\(account)"] - } - - func set(_ data: Data, service: String, account: String) throws { - values["\(service):\(account)"] = data - } - - func delete(service: String, account: String) throws { - values.removeValue(forKey: "\(service):\(account)") - } -} - -private func isolatedDefaults() -> UserDefaults { - let suite = "CredentialStoreTests.\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - return defaults -} - -@Test func migratesTheLegacyIrohSecretOnlyAfterKeychainPersistence() throws { - let keychain = MemoryKeychain() - let defaults = isolatedDefaults() - let legacy = Data(repeating: 0x5A, count: 32) - defaults.set(legacy, forKey: "programa.spike.secretKey") - - let loaded = try SecretKeyStore.loadOrCreate(keychain: keychain, defaults: defaults) - - #expect(loaded == legacy) - #expect(defaults.data(forKey: "programa.spike.secretKey") == nil) - #expect(keychain.values.values.first == legacy) -} - -@Test func replacesACorruptKeychainSecretWithSecurelyGeneratedBytes() throws { - let keychain = MemoryKeychain() - try keychain.set( - Data([0x01]), - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.irohSecretAccount - ) - - let loaded = try SecretKeyStore.loadOrCreate( - keychain: keychain, - defaults: isolatedDefaults() - ) - - #expect(loaded.count == 32) - #expect(loaded != Data([0x01])) -} - -@Test func migratesTheLegacyPairingTicketToKeychain() throws { - let keychain = MemoryKeychain() - let defaults = isolatedDefaults() - defaults.set("iroh-ticket", forKey: "programa.spike.pairingTicket") - - let loaded = try PairingStore.loadTicket(keychain: keychain, defaults: defaults) - - #expect(loaded == "iroh-ticket") - #expect(defaults.string(forKey: "programa.spike.pairingTicket") == nil) - #expect(keychain.values.values.first == Data("iroh-ticket".utf8)) -} - -@Test func deletesACorruptPairingTicketInsteadOfReturningIt() throws { - let keychain = MemoryKeychain() - try keychain.set( - Data([0xFF]), - service: ProgramaCredentialKeychain.service, - account: ProgramaCredentialKeychain.pairingTicketAccount - ) - - let loaded = try PairingStore.loadTicket( - keychain: keychain, - defaults: isolatedDefaults() - ) - - #expect(loaded == nil) - #expect(keychain.values.isEmpty) -} diff --git a/ios/ProgramaSpike/ProgramaSpikeTests/PathClassifierTests.swift b/ios/ProgramaSpike/ProgramaSpikeTests/PathClassifierTests.swift deleted file mode 100644 index 6e5b2799..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeTests/PathClassifierTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import Testing -@testable import ProgramaSpike - -private final class PathPollingScenario { - private(set) var now = ContinuousClock.now - private(set) var sleepCount = 0 - private var observations: [ObservedPath] - - init(observations: [ObservedPath]) { - self.observations = observations - } - - func selectedPath() -> ObservedPath { - guard observations.count > 1 else { return observations.first ?? .unavailable } - return observations.removeFirst() - } - - func sleep(for duration: Duration) async throws { - sleepCount += 1 - now = now.advanced(by: duration) - } -} - -private actor SleepStartSignal { - private var didStart = false - private var waiters: [CheckedContinuation<Void, Never>] = [] - - func markStarted() { - didStart = true - for waiter in waiters { waiter.resume() } - waiters.removeAll() - } - - func waitUntilStarted() async { - if didStart { return } - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } -} - -@Test func selectedPathWaiterReturnsWhenRelayBecomesDirect() async { - let scenario = PathPollingScenario( - observations: [.relay(url: "https://relay.example"), .direct] - ) - - let result = await PathClassifier.waitForSelectedPath( - timeout: .seconds(5), - now: { scenario.now }, - selectedPath: { scenario.selectedPath() }, - sleep: { try await scenario.sleep(for: $0) } - ) - - #expect(result == .direct) - #expect(scenario.sleepCount == 1) -} - -@Test func selectedPathWaiterReturnsLastRelayAtDeadline() async { - let relay = ObservedPath.relay(url: "https://relay.example") - let scenario = PathPollingScenario(observations: [relay]) - - let result = await PathClassifier.waitForSelectedPath( - timeout: .milliseconds(250), - now: { scenario.now }, - selectedPath: { scenario.selectedPath() }, - sleep: { try await scenario.sleep(for: $0) } - ) - - #expect(result == relay) - #expect(scenario.sleepCount == 3) -} - -@Test func selectedPathWaiterStopsAfterCancelledSleep() async { - let relay = ObservedPath.relay(url: "https://relay.example") - let sleepStart = SleepStartSignal() - - let task = Task { - await PathClassifier.waitForSelectedPath( - timeout: .seconds(5), - now: { ContinuousClock.now }, - selectedPath: { relay }, - sleep: { _ in - await sleepStart.markStarted() - try await Task.sleep(for: .seconds(60)) - } - ) - } - - await sleepStart.waitUntilStarted() - let cancelledAt = ContinuousClock.now - task.cancel() - let result = await task.value - - #expect(ContinuousClock.now - cancelledAt < .seconds(1)) - #expect(result == relay) -} diff --git a/ios/ProgramaSpike/ProgramaSpikeWidgets/AgentActivityWidget.swift b/ios/ProgramaSpike/ProgramaSpikeWidgets/AgentActivityWidget.swift deleted file mode 100644 index 2b612591..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeWidgets/AgentActivityWidget.swift +++ /dev/null @@ -1,155 +0,0 @@ -import ActivityKit -import SwiftUI -import WidgetKit - -/// Lock Screen banner, Dynamic Island (compact/expanded/minimal), all driven -/// by `AgentActivityAttributes.ContentState` pushed from `AppStore` on the -/// app side (see that file's "Live Activity" section for the update/ -/// coalescing contract). Blocked is the story everywhere: a human being -/// needed right now is the only state worth interrupting someone for, so it -/// always leads and uses the one non-quiet color (red); the resting state -/// (working or all-clear) stays visually quiet so it doesn't become Lock -/// Screen noise. -struct AgentActivityWidget: Widget { - var body: some WidgetConfiguration { - ActivityConfiguration(for: AgentActivityAttributes.self) { context in - AgentActivityLockScreenView(state: context.state) - } dynamicIsland: { context in - DynamicIsland { - DynamicIslandExpandedRegion(.leading) { - Image(systemName: AgentActivityPresentation.symbolName(for: context.state)) - .font(.title2) - .foregroundStyle(AgentActivityPresentation.tint(for: context.state)) - } - DynamicIslandExpandedRegion(.trailing) { - Text(AgentActivityPresentation.countLabel(for: context.state)) - .font(.title3.weight(.semibold)) - .foregroundStyle(AgentActivityPresentation.tint(for: context.state)) - } - DynamicIslandExpandedRegion(.bottom) { - VStack(alignment: .leading, spacing: 2) { - Text(AgentActivityPresentation.headline(for: context.state)) - .font(.headline) - .foregroundStyle(AgentActivityPresentation.tint(for: context.state)) - if let subheadline = AgentActivityPresentation.subheadline(for: context.state) { - Text(subheadline) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } else { - Text(AgentActivityPresentation.workingSubheadline(for: context.state)) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } compactLeading: { - Image(systemName: AgentActivityPresentation.symbolName(for: context.state)) - .foregroundStyle(AgentActivityPresentation.tint(for: context.state)) - } compactTrailing: { - Text(AgentActivityPresentation.countLabel(for: context.state)) - .foregroundStyle(AgentActivityPresentation.tint(for: context.state)) - } minimal: { - Image(systemName: AgentActivityPresentation.symbolName(for: context.state)) - .foregroundStyle(AgentActivityPresentation.tint(for: context.state)) - } - .keylineTint(AgentActivityPresentation.tint(for: context.state)) - } - } -} - -/// Lock Screen / banner presentation. -private struct AgentActivityLockScreenView: View { - let state: AgentActivityAttributes.ContentState - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: AgentActivityPresentation.symbolName(for: state)) - .font(.title2) - .foregroundStyle(AgentActivityPresentation.tint(for: state)) - .frame(width: 28) - - VStack(alignment: .leading, spacing: 2) { - Text(AgentActivityPresentation.headline(for: state)) - .font(.headline) - .foregroundStyle(AgentActivityPresentation.tint(for: state)) - if let subheadline = AgentActivityPresentation.subheadline(for: state) { - Text(subheadline) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer(minLength: 0) - } - .padding(16) - } -} - -/// Shared string/symbol/color logic for every presentation size (Lock -/// Screen, banner, Dynamic Island compact/expanded/minimal) so they all -/// agree on what "blocked" looks like. All user-facing strings are -/// localizable via `String(localized:)` and now resolve for real: the -/// catalog at `ProgramaSpike/Shared/Localizable.xcstrings` is a member of -/// both this extension and the app, since `Shared/` is a `sources:` path for -/// each target in `project.yml`. A widget extension resolves -/// `String(localized:)` against its own bundle, so a catalog visible only to -/// the app would have left these keys falling back to `defaultValue:`. -enum AgentActivityPresentation { - static func symbolName(for state: AgentActivityAttributes.ContentState) -> String { - state.blockedCount > 0 ? "bell.badge.fill" : "circle.dotted" - } - - /// Semantic colors only: red-orange is reserved for "blocked" so the - /// resting state stays visually quiet instead of becoming Lock Screen - /// noise. `.secondary` and `.primary` both adapt to light/dark - /// automatically. - static func tint(for state: AgentActivityAttributes.ContentState) -> Color { - state.blockedCount > 0 ? .red : .secondary - } - - /// Dynamic Island compact/expanded trailing count: the blocked count - /// when something needs attention, otherwise the working count. - static func countLabel(for state: AgentActivityAttributes.ContentState) -> String { - state.blockedCount > 0 ? "\(state.blockedCount)" : "\(state.workingCount)" - } - - static func headline(for state: AgentActivityAttributes.ContentState) -> String { - if state.blockedCount > 0 { - return blockedHeadline(count: state.blockedCount) - } - if state.workingCount > 0 { - return workingHeadline(count: state.workingCount) - } - return String(localized: "live_activity.headline.allClear", defaultValue: "All clear") - } - - /// Lock Screen subheadline -- only shown for the blocked state, where it - /// is the workspace that most recently needed a human. - static func subheadline(for state: AgentActivityAttributes.ContentState) -> String? { - guard state.blockedCount > 0 else { return nil } - return state.headlineWorkspace - } - - /// Dynamic Island expanded-bottom fallback line when nothing is - /// blocked -- restates the working count since there is no headline - /// workspace to show in that state. - static func workingSubheadline(for state: AgentActivityAttributes.ContentState) -> String { - workingHeadline(count: state.workingCount) - } - - private static func blockedHeadline(count: Int) -> String { - if count == 1 { - return String(localized: "live_activity.headline.blocked.one", defaultValue: "1 agent needs you") - } - return String(localized: "live_activity.headline.blocked.other", defaultValue: "\(count) agents need you") - } - - private static func workingHeadline(count: Int) -> String { - if count == 1 { - return String(localized: "live_activity.headline.working.one", defaultValue: "1 working") - } - return String(localized: "live_activity.headline.working.other", defaultValue: "\(count) working") - } -} diff --git a/ios/ProgramaSpike/ProgramaSpikeWidgets/ProgramaSpikeWidgetsBundle.swift b/ios/ProgramaSpike/ProgramaSpikeWidgets/ProgramaSpikeWidgetsBundle.swift deleted file mode 100644 index 08a3d5fa..00000000 --- a/ios/ProgramaSpike/ProgramaSpikeWidgets/ProgramaSpikeWidgetsBundle.swift +++ /dev/null @@ -1,13 +0,0 @@ -// `Widget` and `WidgetBundle` are declared in SwiftUI, not WidgetKit -- -// importing only WidgetKit compiles the import fine and then fails with -// "cannot find type 'WidgetBundle' in scope", which reads like a missing -// framework rather than a missing import. -import SwiftUI -import WidgetKit - -@main -struct ProgramaSpikeWidgetsBundle: WidgetBundle { - var body: some Widget { - AgentActivityWidget() - } -} diff --git a/ios/ProgramaSpike/README.md b/ios/ProgramaSpike/README.md deleted file mode 100644 index 850728c6..00000000 --- a/ios/ProgramaSpike/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Programa iOS companion — tester setup - -This is the iPhone companion app for Programa. It connects directly to a Mac -running Programa over a private peer-to-peer link (via iroh) — pairing works -over the internet through iroh's relay network, not just when both devices -are on the same Wi-Fi. - -## Setup - -1. On the Mac, open Programa and go to **Settings ▸ Phone**. -2. Set **Mobile Companion** from `Off` to `Paired Devices Only`. It is `Off` - by default, so no phone can connect until you do this. -3. Click **Pair a Device…**. This opens a single-use, 5-minute pairing - window and shows a QR code plus a live countdown. -4. On the iPhone, open the Programa app and tap **Scan QR Code** on the - pairing screen. -5. Point the camera at the QR code on the Mac. The app fills in the pairing - details automatically and connects. - -If scanning isn't possible (no camera access, a Simulator build, or the scan -doesn't work), copy the full pairing code shown below the Mac's QR code. Paste -it into the iPhone's **Pairing code** field and tap **Connect**. - -Once a device pairs successfully it's remembered — the pairing window is -single-use, but reconnecting later doesn't require re-pairing or a new code. - -## iCloud requirement for notifications - -Both the Mac and the iPhone must be **signed into the same iCloud account** -for background notifications (Live Activities, "an agent needs you" alerts) -to arrive. The app has no way to detect a mismatched Apple ID between the two -devices — if notifications never show up, check the Apple ID on both devices -first. - -## Building locally - -```bash -cd ios/ProgramaSpike -xcodegen generate -cd ../.. -xcodebuild -project ios/ProgramaSpike/ProgramaSpike.xcodeproj \ - -scheme ProgramaSpike -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 16' build -``` - -Camera-based scanning has no effect in the Simulator (no camera hardware) — -the **Scan QR Code** button degrades gracefully and shows a message instead -of crashing; use the paste fallback when testing there. diff --git a/ios/ProgramaSpike/project.yml b/ios/ProgramaSpike/project.yml deleted file mode 100644 index 8d5907ae..00000000 --- a/ios/ProgramaSpike/project.yml +++ /dev/null @@ -1,205 +0,0 @@ -name: ProgramaSpike -options: - bundleIdPrefix: com.darkroom.programa -packages: - iroh-ffi: - url: https://github.com/manaflow-ai/iroh-ffi.git - exactVersion: 1.0.2-cmux.3 -targets: - ProgramaSpike: - type: application - platform: iOS - deploymentTarget: "18.0" - sources: - - path: ProgramaSpike - - path: ../../tools/mobile-spike/Sources/MobileSpikeFraming/BoundedLineFramer.swift - group: Shared - dependencies: - - package: iroh-ffi - product: IrohLib - - target: ProgramaSpikeWidgets - embed: true - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike - # App Store Connect already holds a build under version 1.0, because the - # plist used to hardcode that literal and this setting never reached the - # bundle. Now that it does, dropping to 0.1 would push the version string - # backwards against a record that already exists, so this stays at 1.0 - # until the companion's version is deliberately aligned with the Mac app. - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" - SWIFT_VERSION: "6.0" - TARGETED_DEVICE_FAMILY: "1,2" - # Set by scripts/build-ios-testflight.sh before `xcodegen generate`, from - # the name inside the actual installed profile. It has to live here rather - # than on the xcodebuild command line: a command-line build setting applies - # to every target, and the app and the widget need *different* profiles. - # Without it, a manual-signing archive fails with "requires a provisioning - # profile with the iCloud and Push Notifications features". - # Unset locally, where it stays an undefined build-setting reference and - # expands to empty -- harmless, since local builds pass - # CODE_SIGNING_ALLOWED=NO and Xcode itself uses automatic signing. - PROVISIONING_PROFILE_SPECIFIER: ${PROGRAMA_IOS_APP_PROFILE_NAME} - # Black-and-white treatment of the macOS Programa mark. - ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - # Every Info.plist key is declared here, explicitly, rather than via - # INFOPLIST_KEY_* build settings. Two reasons, both learned the hard way: - # - # 1. Xcode synthesises only a fixed allow-list of INFOPLIST_KEY_* names. - # UIBackgroundModes is NOT on it: the setting is accepted silently and the - # key never reaches the built plist. Verified on device -- the key was - # absent from ProgramaSpike.app/Info.plist and iOS logged "you still need - # to add "remote-notification" to the list of your supported - # UIBackgroundModes". Same trap as INFOPLIST_KEY_NSExtensionPointIdentifier. - # 2. Declaring `info.path` sets INFOPLIST_FILE, which turns the INFOPLIST_KEY_* - # synthesis off entirely -- so mixing the two styles silently DROPS every - # key still expressed the old way. That is how NSLocalNetworkUsageDescription - # briefly went missing here, which does not fail the build; it just makes - # iOS deny local-network access and forces every connection onto a relay. - # - # Both failure modes are silent. After changing anything below, verify against - # the built app, not the source: - # plutil -extract UIBackgroundModes json -o - <app>/Info.plist - info: - path: ProgramaSpike/Info.plist - properties: - CFBundleDisplayName: "Programa" - # Declared explicitly because xcodegen's defaults for these two are the - # literals "1.0" and "1", not references to the build settings. Since - # `info.path` sets INFOPLIST_FILE, Xcode only substitutes $(...) in the - # file -- so the CURRENT_PROJECT_VERSION that - # scripts/build-ios-testflight.sh passes on the xcodebuild command line - # never reached the bundle. Every archive shipped as 1.0 (1) regardless - # of the CI build number, which App Store Connect then rejects on the - # second upload as a duplicate bundle version. - CFBundleShortVersionString: "$(MARKETING_VERSION)" - CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" - UILaunchScreen: {} - # Required, not optional: TARGETED_DEVICE_FAMILY is "1,2" so the app - # claims iPad support, and App Store validation HARD FAILS an iPad-capable - # bundle that declares no orientations ("Invalid bundle. No orientations - # were specified"). It surfaces during archive only as a warning - # ("All interface orientations must be supported unless the app requires - # full screen"), so it is easy to ship past locally and only discover at - # upload. - # - # iPad gets all four because that is what iPad multitasking requires. - # iPhone omits upside-down, which is conventional and what Apple's own - # apps do. If this ever becomes iPhone-only, drop TARGETED_DEVICE_FAMILY - # to "1" and the ~ipad variant with it. - UISupportedInterfaceOrientations: - - UIInterfaceOrientationPortrait - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight - UISupportedInterfaceOrientations~ipad: - - UIInterfaceOrientationPortrait - - UIInterfaceOrientationPortraitUpsideDown - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight - # Export-compliance declaration required by App Store Connect. The app - # uses only standard QUIC/TLS (via iroh) and Apple's own CloudKit -- - # no proprietary or non-standard cryptography -- which is the exempt - # case. This is a legal declaration by the publisher: confirm it before - # the first upload rather than treating it as a build setting. - ITSAppUsesNonExemptEncryption: false - # Without this, iOS silently denies the app any local-network access - # and iroh cannot reach the Mac's LAN address from the pairing ticket, - # so the connection falls back to a relay even on the same Wi-Fi. - # Measured: phone reported `relay` until this key was added. - NSLocalNetworkUsageDescription: "Programa connects directly to your Mac on the same network instead of routing through a relay." - # Used only by the pairing screen's "Scan QR Code" button, to read the - # pairing code shown in Programa's Mac Settings ▸ Phone screen. - NSCameraUsageDescription: "Programa uses the camera to scan the pairing QR code shown in Programa's Mac Settings ▸ Phone screen." - # Required for ActivityKit -- without this the app can request a Live - # Activity but the system silently refuses to start it. - NSSupportsLiveActivities: true - # M3: lets a silent (content-available) CloudKit push wake the app in the - # background to refresh the Live Activity. - UIBackgroundModes: - - remote-notification - entitlements: - path: ProgramaSpike/ProgramaSpike.entitlements - properties: - # Required for registerForRemoteNotifications() to succeed at all, which - # CloudKit needs before it can deliver a CKQuerySubscription push. Without - # it the app launches fine and fails at runtime with NSCocoaErrorDomain - # 3000 "no valid aps-environment entitlement string found for - # application", and no push ever arrives. Also requires Push Notifications - # enabled on App ID com.darkroom.programa.spike in the Developer portal. - # Xcode rewrites this to "production" when signing for distribution. - aps-environment: development - com.apple.developer.icloud-container-identifiers: - # Shared with the macOS app so the Mac can write and the phone can - # read the same records. Requires a one-time Developer portal step: - # the container must exist and iCloud must be enabled on BOTH App IDs - # (com.darkroom.programa and com.darkroom.programa.spike). Xcode's - # automatic signing cannot create a container whose name does not - # match the bundle id, so `-allowProvisioningUpdates` will not do it. - - iCloud.com.darkroom.programa - com.apple.developer.icloud-services: - - CloudKit - # Which CloudKit environment the signed build talks to. Unlike - # aps-environment -- which Xcode rewrites from development to production - # when signing for distribution -- this key is NOT injected: leaving it - # out produces a signed binary with no such entitlement at all, and - # build-ios-testflight.sh refuses to upload because it cannot confirm the - # build would reach the same container the Mac writes to. - # Declared as Production because that is the only environment that - # interoperates with the distributed macOS app. The App Store profile - # permits both values (it carries them as an array), so this signs fine. - com.apple.developer.icloud-container-environment: Production - - ProgramaSpikeWidgets: - type: app-extension - platform: iOS - deploymentTarget: "18.0" - sources: - - path: ProgramaSpikeWidgets - - path: ProgramaSpike/Shared - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike.widgets - # App Store Connect already holds a build under version 1.0, because the - # plist used to hardcode that literal and this setting never reached the - # bundle. Now that it does, dropping to 0.1 would push the version string - # backwards against a record that already exists, so this stays at 1.0 - # until the companion's version is deliberately aligned with the Mac app. - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" - SWIFT_VERSION: "6.0" - TARGETED_DEVICE_FAMILY: "1,2" - # The widget needs its own profile, not the app's -- see the note on the - # app target's PROVISIONING_PROFILE_SPECIFIER for why this cannot be - # passed on the xcodebuild command line. - PROVISIONING_PROFILE_SPECIFIER: ${PROGRAMA_IOS_WIDGET_PROFILE_NAME} - # An app extension must ship a real nested NSExtension dictionary. - # INFOPLIST_KEY_* only synthesises a documented allowlist of flat keys, - # so NSExtensionPointIdentifier never nests and iOS refuses to install - # the .appex ("does not define an NSExtension dictionary"). - GENERATE_INFOPLIST_FILE: NO - info: - path: ProgramaSpikeWidgets/Info.plist - properties: - CFBundleDisplayName: Programa Widgets - # Same reason as the app target. These must also track the app exactly: - # App Store validation rejects a bundle whose embedded extension carries - # a different CFBundleVersion than its host app. - CFBundleShortVersionString: "$(MARKETING_VERSION)" - CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" - NSExtension: - NSExtensionPointIdentifier: com.apple.widgetkit-extension - - ProgramaSpikeTests: - type: bundle.unit-test - platform: iOS - deploymentTarget: "18.0" - sources: - - path: ProgramaSpikeTests - dependencies: - - target: ProgramaSpike - settings: - base: - GENERATE_INFOPLIST_FILE: YES - PRODUCT_BUNDLE_IDENTIFIER: com.darkroom.programa.spike.tests - SWIFT_VERSION: "6.0" diff --git a/plans/golden-tumbling-gray.md b/plans/golden-tumbling-gray.md deleted file mode 100644 index 993bb411..00000000 --- a/plans/golden-tumbling-gray.md +++ /dev/null @@ -1,672 +0,0 @@ -# Programa Mobile — watch & unblock agents from your phone - -## Context - -Programa runs coding agents (Claude Code, Codex, OpenCode) in terminal panes across -workspaces. Walk away from the Mac and you lose all visibility: an agent that hits an -approval prompt sits blocked indefinitely, and you find out when you come back. - -This plan adds a mobile companion that closes that loop — see which agents are working vs -stuck, get notified when one needs you, answer it from the phone. - -**Decisions locked with the user before planning:** - -| Decision | Choice | -|---|---| -| Day-one job | **Watch + unblock agents.** Not full terminal emulation. | -| Transport | **P2P, no server we host.** Port the Iroh work from upstream. | -| Stack | **Native iOS, SwiftUI.** | -| Reachability | **Anywhere**, via iroh's default (n0-operated) relay fallback. | -| Notification surface | **Live Activity / widget.** See "Push" — this needs a decision at M3. | - -### Why this is a port, not a greenfield build - -Programa is a fork of `cmux`. Upstream **already shipped this exact app to the App Store** — -`ios/AppStoreReview/` on `upstream/main` carries v1.0.0 metadata and screenshots named -`01-workspaces.png` and `02-notifications.png`. 1,715 Swift files sit under -`Packages/{iOS,Shared}`. - -But the divergence is severe — merge-base `179b16ce67`, with **394 commits ours / 4,706 -theirs**. Upstream restructured into `Packages/` + `Native/`; Programa kept a flat `Sources/`. -So the question is not whether to build it, but how much can cross that gap. - -### What the research established - -**Programa's local control plane is already dashboard-ready.** The hard product modelling -is done: - -- `AgentActivityState` (`Sources/AgentActivityState.swift:18-38`) is a first-class - `working | blocked | idle`, driven by real agent lifecycle hooks (`CLI/CLI+Hooks.swift`). -- **`blocked` already means precisely "a human is needed"** — only an approval prompt - ("Permission") or an idle/AskUserQuestion wait ("Waiting") sets it - (`CLI/CLI+Hooks.swift:604-611,1227-1250,2617-2640`). Everything else maps to `idle`. - This is the notification trigger, already correct. No new detection logic needed. -- `Workspace.aggregateAgentState` (`Sources/AgentActivityState.swift:59-64`) already does the - worst-of rollup (blocked > working > idle) — exactly the shape of a workspace status pill. -- A live event stream exists: `subscribe` in `Sources/TerminalController+Subscriptions.swift` - (spec `docs/v2-api-migration.md:373-450`) with `agent_state` / `output` / - `workspace_lifecycle` classes, coalesced output, and drop-oldest backpressure emitting - `{"event":"dropped","count":N}`. -- The reply path is complete: `surface.send_text`, `surface.send_key`, and composite - `agent.prompt` (send + wait-for-idle, `docs/v2-api-migration.md:257-315`). - -**The entire gap is the network leg.** No TCP/WebSocket/HTTP listener exists anywhere in -`Sources/`. No APNs, no device tokens. Notifications are local `UNUserNotificationCenter` -only (`Sources/TerminalNotificationStore.swift`). Auth is five local access modes -(`Sources/SocketControlSettings.swift:7-61`) topping out at a single shared password. - -**The upstream Iroh transport is liftable — but only half of it.** Its admission model has -two paths, and the split is the single most important fact for the "no server" decision: - -- An **online path** (`CmxIrohOnlineAdmissionRegistry`, `pairGrant` credentials, - `CmxIrohBrokerCredentialRepository`, `CmxIrohAccountRelayConfiguration`) requiring cmux's - own hosted trust broker plus a `StackAuth`-backed account system. **Delete this.** -- An **offline path** (`CmxIrohOfflinePairingSessions` — "one-use Mac invitation state for - offline same-account pairing", `CmxIrohBonjour` LAN discovery, asymmetric - `CmxIrohGrantVerifier`) that needs no account, no broker, no backend. **Keep this.** - -Verified decoupling that makes the lift feasible: no file in `CmuxIrohTransport` imports -`CMUXAuthCore` / `CmuxAPIClient` / `CmuxSyncStore`; `CMUXMobileCore` has zero external -package deps; the only external dependency is public `manaflow-ai/iroh-ffi` pinned at -`1.0.2-cmux.3` — same org Programa already vendors `bonsplit` from. Both packages declare -`platforms: [.iOS(.v18), .macOS(.v14)]`, so one package serves phone and Mac host. - -### What the reference products confirm - -| | Happy | **Orca** | Warp/Oz | -|---|---|---|---| -| Transport | cloud relay, E2E, QR pair | **direct pairing code, no relay** | cloud only | -| Notify on | permission needed / error | agent finished | desktop only | -| Terminal on phone | abstracted to chat feed | read-only scrollback + opt-in Live mode | none | - -**Orca is the closest analog** — a Ghostty-inspired desktop terminal running many CLI agents -across git worktrees, with a phone satellite paired by one-time code and no cloud between. -That is the architecture chosen here, which is good evidence the P2P path works as a product -and not just as engineering. - -All three converge on: **do not put a live PTY on a phone.** Show status, abstract the -terminal to a readable feed, reply in natural language, notify when blocked. - -Programa's `blocked` signal matches Happy's trigger, which is the better of the two for this -job — Orca's "agent finished" tells you too late to unblock anything. - ---- - -## Product shape (v1) - -Three screens. Anything beyond this is out of scope for v1. - -1. **Workspace list** — every workspace with a worst-of state badge from - `aggregateAgentState`. Blocked sorts to the top. This is the glance screen. -2. **Agent detail** — recent output as a readable feed (not a PTY), current state, and a - text box that sends via `agent.prompt`. -3. **Pairing** — one-time code / QR, and a paired-devices list on the Mac with instant revoke. - -Explicitly deferred: live terminal mode, file browser, source control / diff review, browser -session view, workspace creation. - -### Tappable answers instead of a free-text box (scoped 2026-07-28, not built) - -Requested after using the app: when an agent is blocked, the phone should show the agent's -actual question and its choices as buttons, rather than only offering a text field. Answering -"1" to a question you cannot see is the current experience. - -**The two blocked cases are not equally ready, and that is the whole finding.** - -*AskUserQuestion — the options already exist, structured.* `describeAskUserQuestion` -(`CLI/CLI+Hooks.swift:725-748`) already reads `tool_input.questions[].question` and -`options[].label` out of the `PreToolUse` payload. It then **flattens them into one display -string** (`"[Label A] [Label B]"`, line 742) and stores that as `lastBody` in the session -store. So the structure is captured and immediately thrown away. Making these tappable is -plumbing, not new capture: keep the array, carry it through the session store, expose it on -the v2 surface/agent-state payload, render buttons. - -*Permission prompts — no structured options exist.* This is the far more common blocked case, -and `summarizeClaudeHookNotification` (`CLI/CLI+Hooks.swift:1225-1250`) only ever sees free -text: it scrapes `message`/`body`/`text`/`prompt` and truncates to 180 chars. Claude Code does -not hand the hook a choice list here. The realistic move is **not** to parse the prompt text -but to model the fixed, known set of permission answers as actions, and accept that the -button labels are ours rather than the agent's. - -**Sending the answer back is the risky half.** The bridge allow-list already carries -`surface.send_key` and `surface.send_text`, so a tap becomes a key sequence into the TUI. -That is fire-and-forget into a live terminal: if the prompt has already been answered at the -desk, or the agent moved on, those keystrokes land somewhere arbitrary — potentially -selecting an unrelated menu item. Any implementation needs a staleness guard: carry an -identifier for the exact prompt the buttons were rendered from, and have the Mac reject the -tap if the surface's pending prompt is no longer that one. Without it this feature can -silently take destructive actions, which is strictly worse than the text box it replaces. - -**Order of work:** AskUserQuestion first (structure already exists, low risk), the staleness -guard second, permission prompts last. Do not ship any of it before the guard. - ---- - -## Layer strategy - -Three layers, three different tactics, because coupling differs sharply. - -| Layer | Upstream source | Files | Coupling | Tactic | -|---|---|---|---|---| -| Transport spine | `Packages/Shared/{CMUXMobileCore,CmuxIrohTransport}` | 119 + 332 | low (after stripping) | **Vendor & strip** | -| Mac host bridge | `Sources/Mobile/*`, `Packages/macOS/CmuxControlSocket/.../MobileHost/` | small | **very high** | **Write fresh** | -| iOS UI | `Packages/iOS/*` | ~400 | high | **Write fresh** | - -**Cherry-pick is not viable** across 4,706 commits of divergent restructuring. Code crosses -as a **one-time vendored source copy**, materialized with `git show upstream/main:<path>` -into `vendor/CmuxIrohTransport/` and `vendor/CMUXMobileCore/`, following the `vendor/bonsplit` -precedent (local SPM package by path, not a submodule). Each gets a `PROVENANCE.md` recording -the exact upstream SHA, since there is no live tracking afterward. - -Not a live SPM dependency on the fork: `CmuxIrohTransport` is nested inside cmux's monorepo, -not a standalone repo, and every future upstream commit risks silently reintroducing -broker/`StackAuth` coupling we are deliberately deleting. - -### Revised again at M1: build on `IrohLib` directly, keep the package as reference - -The kill criterion "admission is welded to cmux's world → write fresh against bare `iroh-ffi`" -**fired.** Investigation findings: - -- **No cmux key material is hardcoded.** `CmxIrohGrantVerifier.publicKey(id:keySet:)` - (`CmxIrohGrantVerifier.swift:247-278`) only checks structural shape — version, key count, - valid Ed25519 SPKI DER. Keys are runtime values. So a self-signing broker is *possible*. -- **But the format contract is not injectable, only the keys are.** Conforming means - reimplementing cmux's exact JWT-like claim sets, base64url/SPKI-DER encodings and lifetime - windows, with no ability to simplify. -- **`CmxIrohHostRuntime.start()` has no broker-free path.** It unconditionally calls - `register` (`+PolicyRefresh.swift:107`), `discover` (`:121`) and `issueEndpointAttestation` - (`:145`) before `endpointServer.start()` accepts anything. The cached-policy fallback - (`:161-195`) requires a *prior successful* round trip. Even offline-paired peers keep hitting - `broker.discover()` every 30s via `CmxIrohOnlineAdmissionRegistry.authorizeOfflinePair` - (`:117-134`, `:338`). There is no partial-conformance shortcut. -- **What the runtime adds is mostly inapplicable to us**: managed-relay credential rotation - (dead unless `managedRelayURLs` is non-empty, `CmxIrohHostRuntime.swift:218-220`), LAN - rendezvous rotation, fleet-scale revalidation. M0 already connects over LAN *and* cellular - with none of it. - -**Decision: the Mac listener is built directly on `IrohLib`**, exactly as the proven M0 spike -is (`tools/mobile-spike/Sources/iroh-spike/App.swift`). The vendored package stays in the tree -as **reference**, not as a compiled dependency — deleting it is easy later and reversible; -re-extracting it is not free. Worth rereading when needed: `CmxIrohStreamHeader*` (lane framing -for when one control channel isn't enough), `CmxIrohAdmittedConnectionSupervisor` -(control/application lane race-and-close), and `CmxIrohGrantVerifierTests.swift` as an -attack-case checklist if pairing ever grows into signed grants. - -### v1 pairing: the EndpointID *is* the credential - -iroh's EndpointID is an Ed25519 public key, and QUIC mutually authenticates it. So -authorization is **set membership**, with nothing forgeable: - -1. Mac generates one long-lived `SecretKey` on first launch, stored in the Keychain. That is - its identity — no separate broker key. -2. Pairing shows a QR/ticket: the Mac's EndpointID plus a random, memory-only, single-use - token that expires in ~5 minutes. -3. Phone dials the EndpointID and presents the token on the first control message. -4. Mac verifies the window is open, the token matches (constant-time) and is unconsumed, then - persists the phone's EndpointID to a trusted-device store and closes the pairing window. -5. Every later connection is authorized purely by "is this EndpointID in the store". -6. Revocation is removing the entry and dropping open connections — local and immediate. - -An unpaired peer cannot enter the allowlist except through a deliberate, time-boxed ceremony, -and there is no signed artifact it could forge to talk its way in. - -### Superseded: inject an offline broker, don't delete files - -The original plan assumed we'd delete ~12 broker files and untangle the 29 that reference -them. **Reading the code showed that's wrong and unnecessary.** - -`CmxIrohHostBrokerServing` is a *protocol* (`CmxIrohHostBrokerServing.swift`), and -`CmxIrohHostRuntime` takes `any CmxIrohHostBrokerServing` by injection -(`CmxIrohHostRuntime.swift:62,113,132`). `CmxIrohTrustBrokerClient` — the HTTP client that -talks to cmux's hosted backend — is just one conformance, attached by a one-line extension. -The 29 "coupled" files depend on **the seam, not the server**. - -So: - -- **Write** `ProgramaOfflineBroker: CmxIrohHostBrokerServing` — five methods total: - `discover()`, `issueRelayToken(bindingID:endpointID:)`, `revoke(bindingID:)`, - `register(prepared:signer:)`, `issueEndpointAttestation(bindingID:)`. Backed by - `CmxIrohOfflinePairingSessions` (which already exposes `createInvitation` / - `verifyAndConsume` / `setPairingEnabled` / `revoke`) plus local storage. No network. -- **Never construct** `CmxIrohTrustBrokerClient`. Enforce with a build-time check that the - type is unreferenced from Programa code, so the HTTP path is provably dead rather than - merely unused. -- **Delete nothing** in the initial vendoring. The vendored tree stays byte-identical to the - recorded upstream SHA, which keeps `PROVENANCE.md` honest and re-extraction trivial. Prune - genuinely dead files later, as a separate cleanup, once the offline path is proven. - -This is both cheaper and safer than deletion: no risk of breaking the 421 passing tests by -severing something subtle, and the security property we wanted ("the broker path is not -reachable") is achieved by never injecting it. - -Iroh's n0-operated relay fallback stays — third-party infrastructure that already exists, -not a server we run. - -**Why the Mac bridge is written fresh, not lifted:** upstream's flat `Sources/Mobile/*` imports -`StackAuth` / `CmuxAuthRuntime` / `CmuxSettings` — cmux's account system, which Programa has -none of and doesn't want. And `Packages/macOS/CmuxControlSocket/.../MobileHost/*` dispatches -`mobile.terminal.create/input/replay/viewport/scroll/mouse` — a full terminal-mirroring data -plane, i.e. exactly the scope that was ruled out. - -**Why the iOS UI is written fresh:** `CmuxMobileWorkspace` is account-gated -(`MobileRootAuthGate`, `MobileOnboardingGate`, `SignInCodeInputPolicy` all live inside it), and -`CmuxMobileShellUI` is 193 files of full terminal rendering. Both are the wrong shape for a -status-list-plus-prompt-box product. The App Store screenshots are worth reading as **UX -reference**; `CmuxMobilePairedMac` (14 files) is worth imitating for paired-Mac persistence — -neither is worth copying. - ---- - -## Mac host bridge design - -New flat directory `Sources/MobileBridge/`, matching Programa's convention -(`WorkspaceRemoteCLIRelayServer.swift` is the closest existing analog and is also a flat file): - -- `MobileBridgeListener.swift` — owns the stripped transport runtime: binds an Iroh endpoint, - advertises via `CmxIrohBonjour`, wires `CmxIrohAdmissionController` restricted to offline - pairing. -- `MobileBridgeSession.swift` — one per admitted peer; owns the frame relay. -- `MobileBridgeSettings.swift` — pairing mode + device revocation list. - -### It's a relay, not a translator — via a socketpair - -Phone and Mac already speak identical newline-delimited JSON-RPC. Upstream needed a -translation layer only because their Mac side moved to a typed -`ControlCallResult`/`ControlCommandCoordinator`; Programa's did not. - -**Revised after reading the code.** The original plan said `MobileBridgeSession` should -"construct a real `SocketConnection` wrapping its Iroh lane." That is not possible: -`SocketConnection` is a concrete `final class` holding a raw fd (`private let socket: Int32`, -`Sources/TerminalController+Subscriptions.swift:37-46`) and writing to it directly. It cannot -wrap anything that is not a file descriptor. Making it protocol-based would mean refactoring -the subscription machinery. - -There is a much cheaper path. `handleClient(_ socket: Int32, peerPid: pid_t? = nil)` -(`Sources/TerminalController.swift:1390`) already takes an arbitrary fd and an *injectable* -peer PID. So per admitted phone: - -1. `socketpair(AF_UNIX, SOCK_STREAM, 0, &fds)` — two connected fds, no filesystem, no listener. -2. `Thread.detachNewThread { handleClient(fds[0], peerPid: getpid()) }` — the existing read - loop, v2 dispatch, subscription lifecycle, event pushes and backpressure all run untouched. -3. Pump bytes both ways between `fds[1]` and the peer's Iroh bidirectional stream. -4. Enforce the method allow-list on lines read from the phone *before* they reach `fds[1]`. - -**Nothing in `SocketConnection`, `processV2Command`, or the subscription code changes.** The -only edit to existing Programa code is bumping `handleClient` from `private` to `internal` — -one line. Subscription push frames flow back to the phone automatically because, as far as -Programa is concerned, this is just another socket client. - -This also means **`agent.prompt` is the phone's entire answer/approve action with zero new -Mac-side logic** — the same call the CLI's `prompt-agent` already uses. - -Note the socketpair deliberately does *not* rely on `cmuxOnly` ancestry checks for security -(`Sources/TerminalController.swift:1400-1425`). The phone's authorization boundary is the -Iroh admission handshake plus the bridge's own method allow-list, both entirely upstream of -this fd. The socketpair is a plumbing convenience, not a trust boundary. - -### Keep pairing orthogonal to `SocketControlSettings` — do not add a sixth access mode - -The five existing modes answer "who may open `programa.sock`" using a **process-trust** -primitive (ancestry, shared password, file permissions). An Iroh peer is never a local process -— it's a remote peer admitted by an **asymmetric pairing credential**, a categorically -different primitive. A paired phone has no meaningful "socket file permissions"; under -in-process dispatch it never touches the socket file at all. - -So: a separate `MobileBridgeMode { off, pairedDevicesOnly }` with its own Settings section -("Phone pairing"), its own persistence, and its own instant local revoke. The phone's -authorization boundary sits entirely *upstream* of the socket, at Iroh admission time — it -never widens the existing gate. - -### Security implications, stated plainly - -- **Scope creep is the real risk.** In-process dispatch inherits the *entire* v2 method - surface by default — including `worktree.remove`, `browser.navigate`, `debug.*`. **Enforce - an explicit method allow-list inside `MobileBridgeSession` before dispatch**, scoped to - `system.ping`, `workspace.list`, `surface.list`, `subscribe`, `unsubscribe`, `agent.prompt`, - `surface.send_text`, `surface.send_key`. Anything else returns `forbidden`. This list must - be revisited whenever a v2 method is added, or the phone silently gains unreviewed - capabilities. -- **Revocation must be instant and local** — no broker round-trip (we deleted that path), so a - lost phone is killed from the Mac immediately. -- **Never put prompt or output text in a notification payload.** Keep it generic ("Agent needs - input in `<workspace>`") and have the phone pull real state over the authenticated Iroh - session on foreground. -- This is **materially safer than `SocketControlSettings.password` mode**, which is one shared - secret in a 0600 file. The offline-pairing credential is asymmetric — no shared-secret leak - surface — *provided* the broker/`pairGrant` path is genuinely deleted rather than merely - unused. - ---- - -## Resumability - -**Decision: full-resync on reconnect. Do not add sequence numbers or a replay buffer.** - -`surface.list` already returns complete `agent_state`/`agent_state_source` per surface -(`Sources/TerminalController+Surface.swift:9-72`) — that *is* the entire payload the watch -screen needs, so there is no cheaper resync than fetching the source of truth. It matches the -already-documented recovery contract for `dropped` frames, and has no buffer-expiry edge cases. - -Sequence numbers would require a genuinely new mechanism: a subscription-independent bounded -ring buffer keyed by durable subscriber identity, seq threaded through every event type, a -`since_seq` parameter, and replay-correctness handling — none of which any existing consumer -needs. Today's `EventSubscription` is created fresh per `subscribe` and has zero persistence -across a torn-down connection; a reconnecting phone is a *new* subscription, not a resuming one. - -Phone-side discipline: treat every I/O error identically (dropped frame, closed socket, cold -launch) — full resync once, debounced against drop bursts, then re-`subscribe` for the live -tail. - -Revisit only if field data shows `surface.list` latency becoming user-visible. For one -developer's Mac with dozens of surfaces, it won't. - ---- - -## Push / Live Activity - -**Honest constraint:** APNs requires a provider holding an auth key to talk to Apple, and iOS -suspends backgrounded apps. A Live Activity is a *presentation* choice — it still needs a push -path for remote updates. So the "no server we host" decision and the "notify me when blocked" -requirement are in genuine tension, and this plan does not paper over that. - -**v1 (M2, free):** the Live Activity updates locally whenever the Iroh session is alive — -foreground, and during the windows iOS grants background execution. Real value, zero -infrastructure, no APNs entitlement. - -**M3 decision — two paths, costed, to be chosen then rather than now:** - -- **Mac as APNs provider.** The Mac holds the push key and POSTs to `api.push.apple.com` - directly. Literally no server. Cost: the signing key ships inside the app, so anyone who - extracts it can push to any device token they know. Acceptable for a personal/small-team - tool, not for broad distribution. -- **Minimal stateless relay.** One function whose only job is signing and forwarding a push. - Keeps the key server-side. Paired with a Notification Service Extension so the relay handles - only ciphertext and never sees workspace names. Cost: one hosted piece, contradicting the - literal "no server" constraint. - -If the line on zero hosted infrastructure holds firm and Mac-as-provider is rejected, the -honest outcome is that notifications work only while the app is reachable, and the product -downgrades from "tells you when an agent is blocked" to "shows you when you open it." That is -a materially smaller product and worth a real conversation at M3 rather than a silent scope cut. - ---- - -## Milestones - -Riskiest assumption first. Sizes assume one engineer. - -**M0 — Transport reachability spike. 1–2 weeks.** -No JSON-RPC, no UI. Vendor and immediately strip the two packages to the offline-pairing -branch; get `iroh-ffi@1.0.2-cmux.3` resolving and linking for both a macOS and a throwaway iOS -target; smallest possible Mac listener (bind, Bonjour advertise, accept one paired connection) -and iOS client (enter code, connect, echo bytes). -**Success:** a real Mac and a real iPhone hold a connection for several minutes, across both -LAN and cellular-to-home-WiFi. -*Touches:* `vendor/CmuxIrohTransport/**`, `vendor/CMUXMobileCore/**`, `GhosttyTabs.xcodeproj`, -disposable iOS test target. - -**M1 — Mac host bridge, relay only, no UI. 1–2 weeks.** -`Sources/MobileBridge/{MobileBridgeListener,MobileBridgeSession,MobileBridgeSettings}.swift`; -bump `processV2Command` visibility; implement the method allow-list; add the pairing-mode -setting and device revoke. Validate from a scripted Iroh test client sending raw JSON-RPC — -confirm `subscribe` events flow and `agent.prompt` round-trips. -*Touches:* `Sources/MobileBridge/*` (new), `Sources/TerminalController.swift` (visibility -only), new Settings panel. - -**M2 — iOS MVP: the actual watch-and-unblock. 2–3 weeks.** -Pairing flow, workspace list with state badges from `surface.list` + live `subscribe`, agent -detail with `agent.prompt` entry, Live Activity updating while connected. -*Touches:* new iOS app target, new small Swift package consuming only the vendored transport. - -**M3 — Remote push via CloudKit. 1–2 weeks.** Decided 2026-07-28 after research. - -**Why CloudKit, and why not the obvious alternatives.** APNs tokens are bound to the app's -bundle ID and Team ID, so there is no per-user key: any provider pushing to our companion must -hold *our* `.p8`. Programa is publicly distributed, so shipping that key makes it extractable. -CloudKit escapes this entirely — each user's Mac writes to *their own* iCloud private database, -their own phone is subscribed, Apple delivers. No key distributed, no infrastructure we run, -per-user isolation by construction. - -Research findings that settled it: - -- **Happy Coder** claims "encrypted, we can't see the content" but - `packages/happy-server/sources/app/push/pushSend.ts` POSTs **plaintext** title/body to Expo's - public API. Expo holds the APNs key — including for self-hosters, since the push token is - minted by Expo. Their E2E encryption covers message content, not notifications. -- **Home Assistant** — the closest analogue (thousands of self-hosted servers, one public iOS - app) — routes through a **centrally-operated relay**. `homeassistant/components/mobile_app/ - notify.py` POSTs plaintext title/body to a `push_url`; HA core holds no APNs key. Free, - 500/day/target, not gated behind Nabu Casa. Notably `push_notification.py` tries a *local* - channel first and only falls back to cloud push after ~10s — the same p2p-first shape we have. -- **Orca** does **no real push**: `mobile/src/notifications/` uses - `scheduleNotificationAsync(trigger: nil)` — local only, while a live RPC connection exists. - Their "no cloud relay" claim is true because a backgrounded phone is never woken. - -**Nobody solves this without a relay or without giving up backgrounded wake.** CloudKit is the -only found path that gets both. - -**Design:** - -1. One `CKRecord` in the user's **private** database holding current blocked-agent summaries. -2. `CKQuerySubscription` created by the iOS app at pairing, with **both** - `alertBody` (no `soundName`) **and** `shouldSendContentAvailable = true`. The alert promotes - the push to the reliable high-priority channel and shows a lock-screen line without buzzing; - the same delivery wakes the app to refresh its Live Activity locally. -3. **Alert text stays generic** ("An agent needs you"). Workspace names live only in the record, - inside the user's own private DB — so the payload transiting Apple carries nothing - meaningful. Stronger than Happy, which sends full plaintext to a third party. -4. **Foreground reconciliation is mandatory.** Silent pushes are coalesced to the latest, - throttled ("two or three per hour"), and **discarded entirely after a force-quit**. The app - must re-read the record and rebuild Live Activity state on every foreground. -5. **Gate on `CKContainer.accountStatus == .available`** and check both devices share an Apple - ID during pairing. Mismatched accounts deliver nothing and raise **no error** — a silent - failure mode. - -**Live Activities are demoted, not deleted.** The notification is the contract; the Live -Activity is a bonus that refreshes when the silent push gets through. It is iOS-only and its -freshness rides the least reliable channel Apple offers, so it gets no further investment. - -### Provisioning-profile groundwork done now, entitlement flip still gated - -The release pipeline can embed a Developer ID provisioning profile -(`scripts/sign-release-app.sh` copies `$PROGRAMA_PROVISION_PROFILE` to -`Contents/embedded.provisionprofile` before the app is signed, wired through -`.github/workflows/release.yml` via an optional `APPLE_PROVISION_PROFILE_BASE64` secret, -verified with `scripts/verify-provision-profile.sh`). None of this enables CloudKit by -itself — `programa.entitlements` still carries no iCloud keys, and -`MobileBridgePush.releaseProvisioningComplete` stays `false` until the steps below are done. -**Steps 1 and 2 were completed on 2026-07-28.** What exists at Apple now: - -- iCloud container `iCloud.com.darkroom.programa` — Active. -- App ID `Programa` / `com.darkroom.programa` — registered with the iCloud capability - (CloudKit) and the container attached. It did **not** exist before: the Mac app is - Developer-ID-signed with no provisioning, so nothing had ever needed one. -- Provisioning profile `Programa Developer ID CloudKit` — Developer ID Application, platform - `OSX`, `ProvisionsAllDevices: true`, expires 2044-07-23. Verified to carry - `com.apple.application-identifier = ZNHHMX2RP6.com.darkroom.programa`, - `com.apple.developer.icloud-services = *`, and - `com.apple.developer.icloud-container-identifiers = [iCloud.com.darkroom.programa]`. - Stored as the `APPLE_PROVISION_PROFILE_BASE64` GitHub secret. -- iOS App ID `com.darkroom.programa.spike` already had the same container attached, so it - needed no change — but its profiles were minted *before* the attachment and carry no - containers. Xcode regenerates them on the next companion build; verify before assuming - the phone can subscribe. - -Two traps worth recording, both of which cost time here: - -- **Xcode's Signing & Capabilities editor cannot finish this.** Setting a team on the - `GhosttyTabs` target makes Xcode attempt an automatic *Development* profile, which fails - with "Device … isn't registered in your developer account". That error is a dead end, not a - blocker to solve: Developer ID profiles set `ProvisionsAllDevices`, so no device - registration is involved. The editor also rewrites ~2,700 lines of `project.pbxproj`, adds - `CODE_SIGN_ENTITLEMENTS`, and reformats every shared scheme — all of which conflicts with - this project's post-build `codesign --entitlements` approach and must be reverted. -- **Registering the App ID must happen before the profile.** The profile wizard only lists - existing App IDs, and `com.darkroom.programa` was not among them. - -**Still required, in order:** - -3. **Add the entitlement** to `programa.entitlements` - (`com.apple.developer.icloud-services`, `com.apple.developer.icloud-container-identifiers`) - in the same change that flips `MobileBridgePush.releaseProvisioningComplete` to `true` — - not before. Confirm `scripts/verify-provision-profile.sh` reports the profile as present, - unexpired, and granting exactly those entitlements. -4. **Mandatory launch-test of the *notarized* build before shipping to `main`.** Notarization - only checks the code signature and scans for malware — it does **not** evaluate whether a - restricted entitlement matches an embedded profile. AMFI does that check at launch time, - on-device, every time. A profile/entitlement mismatch signs cleanly, notarizes cleanly, - staples cleanly, and then the app is silently killed the moment it launches (POSIX 163 — - this project has hit exactly this failure before, see - `restricted-entitlements-brick-app` memory). So: download the actual notarized, - stapled `.dmg` from a **dry-run** workflow_dispatch run (never test straight off `main`'s - auto-ship), install it, and confirm the app actually launches and CloudKit initializes - before that commit is allowed to reach `main`. - -### The schema had to be deployed by hand — Production will not create it for you - -Verified 2026-07-28 in CloudKit Console: container `iCloud.com.darkroom.programa` contains -exactly one record type, `Users`, in **both** the Development and Production environments. -`AgentStatus` does not exist anywhere. That single fact explains the phone's runtime errors: -querying or subscribing to a record type that has never been defined is rejected with -`CKError 15/2000 "Server Rejected Request"`, which reads like an entitlement or auth problem -and is not one. - -Why it will not fix itself once the Mac starts writing: - -- **Development auto-creates record types on first save. Production never does.** Production - schema only ever arrives via *Deploy Schema Changes* from Development. So the Mac's first - write does not bootstrap it. -- **The Mac writes to Production.** Its Developer ID profile pins - `com.apple.developer.icloud-container-environment = Production` (confirmed in the embedded - profile of the notarized dry-run build). So the Mac will hit the same rejection the phone - does, for the same reason. -- **A Debug phone build reads Development.** Even with the schema deployed, a locally-built - companion and a Developer-ID Mac are pointed at two different databases and will never see - each other's records. End-to-end verification needs a Release/TestFlight build of the - companion, or a deliberately dev-signed Mac writer. - -**Resolved 2026-07-28.** `AgentStatus` was created in Development and deployed to Production, -with the fields `MobileBridgePush.performSave` actually writes -(`Sources/MobileBridge/MobileBridgePush.swift:209-221`): - -| field | type | -|---|---| -| `blockedCount` | Int64 | -| `workingCount` | Int64 | -| `mostRecentBlockedWorkspaceTitle` | String | - -The record name is fixed at `agent-status-summary` — a record ID, not a field, so it is not -part of the schema. The `CKQuerySubscription` uses `NSPredicate(value: true)` -(`ios/ProgramaSpike/ProgramaSpike/CloudKitPush.swift:46-51`), which requires the type to be -queryable, so a QUERYABLE single-field index on `recordName` was added alongside it. - -Verified in the Production environment after deploying: `AgentStatus`, 9 fields, `recordName` -REFERENCE Queryable, and all three custom fields present. The deployment diff contained only -that record type, that index, and the three default security-role entries CloudKit attaches -to any new type. - -**Still unverified on device:** the phone was disconnected before the subscription could be -re-attempted, so `CKError 15/2000` has not yet been observed to clear. That is the first thing -to check when the companion is next run — and note the environment split above still applies, -so a Debug phone build exercises Development, not the Production schema the Mac will write to. - -**Known limit — this is an iOS-only bet.** CloudKit cannot serve an Android companion. If -Android happens (plausible if Programa reaches Windows), push gets rebuilt around a relay that -fans out to APNs and FCM. The *transport* generalizes fine — `iroh-ffi` is uniffi-based, so -Kotlin bindings are achievable — only push is platform-locked. Accepted deliberately: pay for -the second path when it is real. - -**M4 — Hardening. 1–2 weeks.** -Resync discipline, background-refresh tuning, multi-device pairing, App Store prep. - ---- - -## Risks & kill criteria - -Each has a concrete tripwire, discoverable at a named milestone. - -### Retired by the M0 spike (2026-07-27) - -- ~~**Offline pairing won't cleanly separate from the broker at compile time.**~~ **Retired.** - Separation doesn't require surgery — broker access is a protocol seam, satisfied by - injecting our own conformance. See the revised layer strategy above. -- ~~**`iroh-ffi@1.0.2-cmux.3` won't build/link on current Xcode.**~~ **Retired.** It resolves - to a prebuilt, checksummed binary xcframework from a GitHub release (no Rust toolchain - needed), carrying `ios-arm64`, `ios-arm64_x86_64-simulator`, and `macos-arm64_x86_64` - slices. The vendored package builds clean in 19s on Xcode 26.3 / Swift 6.2.4, and its own - suite passes **421 tests across 50 suites** — including - `relayDisabledEndpointsCarryAuthenticatedBidirectionalRoundTrip`, a live QUIC round-trip - with relay disabled. -- ~~**Admission crypto lives inside the Rust layer in a cmux-specific way.**~~ **Retired.** - Admission is Swift-side and seam-injected; the Rust layer is generic iroh. - -- ~~**Real-device reachability across networks.**~~ **Retired — M0 passed on hardware.** - A real iPhone 16 Pro connected to the Mac and echoed the probe byte-exact on both networks: - `private network` on shared Wi-Fi, and **`direct` on cellular with Wi-Fi off** — hole-punched - through carrier NAT to a home router, no relay, no server. Confirmed from both ends - independently. **The "P2P, no server we host" transport choice is validated as specified.** - -### Still open - -- **Xcode version skew.** We're on 26.3; upstream pinned 26.0 (`.xcode-version`). Everything - builds today, but the vendored code was never CI-tested against 26.3. *Tripwire:* CI. -- **Direct-path reliability, not just possibility.** M0 proves hole-punching *can* work on this - carrier and router. It says nothing about how often it fails on other networks (symmetric - NAT, corporate Wi-Fi, CGNAT). The app must treat `relay` as a normal outcome and stay - correct on it — only latency should change. Worth instrumenting the direct-vs-relay ratio - once real devices are in use. - -### Traps M0 surfaced — read before M1 - -Three separate settings each produced a green-looking result that proved the *opposite* of -what we wanted. All three are inherited from cmux and are wrong for a broker-less deployment: - -1. **`presetMinimal()`** — iroh documents it as "no external dependencies; good for tests / - offline". cmux uses it because their hosted broker supplies discovery. With it, a connection - *never* leaves the relay: two processes on the same machine stayed relayed at 385 ms. - `presetN0()` (relays **and** discovery) took the identical exchange to 1.1 ms peer-to-peer. -2. **Missing `NSLocalNetworkUsageDescription`** — iOS silently denies all LAN access without - it, so the phone could not reach the Mac's `192.168.1.33` and fell back to relay *on the - same Wi-Fi*. A missing Info.plist key is indistinguishable from "NAT traversal failed" - unless you know to look. -3. **Path classifier returning the first path** — iroh always relays first and upgrades after - hole-punching, so reporting the first selected path reads `relay` essentially always. The - classifier must wait for the settled path or the measurement is meaningless. - -The common thread: **a relayed connection looks identical to a working one unless you measure -the path.** Any M1 diagnostics must surface direct-vs-relay prominently, not bury it. -- **Relay fallback doesn't reliably bridge cellular-to-home-WiFi.** *Tripwire:* M0 field test. - → Go/no-go moment for the whole transport choice, not a bug to fix later. Surface before - committing to M1, because the tempting fix (self-host a relay) walks back the core decision. -- **In-process dispatch unsafe from an Iroh callback thread** (hidden reentrancy not visible - from the stated threading policy). *Tripwire:* M1. → Doesn't kill the project; fall back to - the long-lived socket connection. More code, still viable. -- **Admission crypto turns out to live inside the Rust layer in a cmux-protocol-specific way.** - *Tripwire:* M0 echo server. → Write fresh against bare `iroh-ffi` with a simpler - personal-pairing scheme. - ---- - -## Verification - -Per CLAUDE.md, **tests are never run locally** — E2E/UI via `gh workflow run test-e2e.yml`, -unit tests preferred through CI. - -- **Builds:** `./scripts/reload.sh --tag mobile-bridge` for every Mac-side change. Never bare - `xcodebuild`, never an untagged `Programa DEV.app`. -- **New Swift files must be added to `project.pbxproj`** (4 manual entries) or the build fails - with "cannot find type in scope" — a known recurring miss. -- **M0:** manual two-device test. Log connection establishment, relay-vs-direct path, and - sustained duration to the debug event log via `dlog()` (requires `import Bonsplit`, wrapped - in `#if DEBUG`). -- **M1:** new `tests_v2/` python test driving the bridge over a loopback Iroh connection — - assert allow-listed methods succeed, non-allow-listed return `forbidden`, and `subscribe` - frames arrive. Follow the `tests_v2` authoring rules (marker-in-echo, own connection for - concurrent sends, client timeout headroom). Never point tests at the production socket. -- **M2:** manual device testing against a tagged Debug build. -- **Regression tests for bugs** follow the two-commit policy — failing test first (CI red), - then the fix (CI green). -- **All user-facing strings localized** via `String(localized:)` with keys in - `Resources/Localizable.xcstrings` (English + Japanese). -- **New keyboard shortcuts**, if any, must land in `KeyboardShortcutSettings`, be editable in - Settings, be supported in `~/.config/programa/settings.json`, and be documented. diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 70d790e1..8a67b467 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -4100,45 +4100,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } -#if DEBUG - XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) -#else - XCTFail("debugHandleCustomShortcut is only available in DEBUG") -#endif - } - } - - func testReactGrabShortcutIsConsumedWhenNoBrowserRouteExists() { - guard let appDelegate = AppDelegate.shared else { - XCTFail("Expected AppDelegate.shared") - return - } - - let windowId = appDelegate.createMainWindow() - defer { closeWindow(withId: windowId) } - - guard let window = window(withId: windowId) else { - XCTFail("Expected test window") - return - } - - withTemporaryShortcut(action: .toggleReactGrab) { - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.command, .shift], - timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: window.windowNumber, - context: nil, - characters: "G", - charactersIgnoringModifiers: "g", - isARepeat: false, - keyCode: 5 - ) else { - XCTFail("Failed to construct Cmd+Shift+G event") - return - } - #if DEBUG XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) #else @@ -5615,24 +5576,6 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(activateApplicationCallCount, 1) } - func testPresentPreferencesWindowForwardsBrowserImportNavigationTarget() { - var receivedNavigationTarget: SettingsNavigationTarget? - var activateApplicationCallCount = 0 - - AppDelegate.presentPreferencesWindow( - navigationTarget: .browserImport, - showFallbackSettingsWindow: { navigationTarget in - receivedNavigationTarget = navigationTarget - }, - activateApplication: { - activateApplicationCallCount += 1 - } - ) - - XCTAssertEqual(receivedNavigationTarget, .browserImport) - XCTAssertEqual(activateApplicationCallCount, 1) - } - // MARK: - Non-Latin keyboard layout shortcut tests func testBrowserFirstFindShortcutRoutingRecognizesFindCommandFamily() { diff --git a/programaTests/BrowserConfigTests.swift b/programaTests/BrowserConfigTests.swift index 91ea6366..17ba4edf 100644 --- a/programaTests/BrowserConfigTests.swift +++ b/programaTests/BrowserConfigTests.swift @@ -15,58 +15,6 @@ import os @testable import Programa #endif -final class BrowserExtensionConsentStoreTests: XCTestCase { - func testAuthorityFingerprintIsOrderIndependentButChangesWithManifestAuthority() { - let baseline = BrowserExtensionConsentStore.fingerprint( - candidateID: "/extensions/password-manager", - version: "1.0", - permissions: ["tabs", "storage"], - hostPatterns: ["https://example.com/*", "https://login.example/*"] - ) - XCTAssertEqual( - baseline, - BrowserExtensionConsentStore.fingerprint( - candidateID: "/extensions/password-manager", - version: "1.0", - permissions: ["storage", "tabs"], - hostPatterns: ["https://login.example/*", "https://example.com/*"] - ) - ) - XCTAssertNotEqual( - baseline, - BrowserExtensionConsentStore.fingerprint( - candidateID: "/extensions/password-manager", - version: "1.1", - permissions: ["storage", "tabs"], - hostPatterns: ["https://login.example/*", "https://example.com/*"] - ) - ) - XCTAssertNotEqual( - baseline, - BrowserExtensionConsentStore.fingerprint( - candidateID: "/extensions/password-manager", - version: "1.0", - permissions: ["storage", "tabs"], - hostPatterns: ["<all_urls>"] - ) - ) - } - - func testChangedManifestDoesNotInheritApprovalAndRevocationPersistsDenial() throws { - let suiteName = "BrowserExtensionConsentStoreTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - let store = BrowserExtensionConsentStore(defaults: defaults, defaultsKey: "decisions") - - store.setDecision(.approved, candidateID: "candidate", fingerprint: "manifest-a") - XCTAssertEqual(store.decision(candidateID: "candidate", fingerprint: "manifest-a"), .approved) - XCTAssertNil(store.decision(candidateID: "candidate", fingerprint: "manifest-b")) - - store.setDecision(.denied, candidateID: "candidate", fingerprint: "manifest-a") - XCTAssertEqual(store.decision(candidateID: "candidate", fingerprint: "manifest-a"), .denied) - } -} - private actor BrowserSuggestionRequestRecorder { private(set) var requestedHosts: [String] = [] @@ -1451,14 +1399,6 @@ final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.control) } - func testDefaultShortcutForToggleReactGrabUsesCommandShiftG() { - let shortcut = KeyboardShortcutSettings.Action.toggleReactGrab.defaultShortcut - XCTAssertEqual(shortcut.key, "g") - XCTAssertTrue(shortcut.command) - XCTAssertFalse(shortcut.option) - XCTAssertTrue(shortcut.shift) - XCTAssertFalse(shortcut.control) - } } diff --git a/programaTests/BrowserImportMappingTests.swift b/programaTests/BrowserImportMappingTests.swift deleted file mode 100644 index 5466a9f4..00000000 --- a/programaTests/BrowserImportMappingTests.swift +++ /dev/null @@ -1,417 +0,0 @@ -import XCTest - -#if canImport(Programa_DEV) -@testable import Programa_DEV -#elseif canImport(Programa) -@testable import Programa -#endif - -final class BrowserImportMappingTests: XCTestCase { - @MainActor - func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() { - let defaultProfile = BrowserProfileDefinition( - id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, - displayName: "Default", - createdAt: .distantPast, - isBuiltInDefault: true - ) - let sourceProfiles = [ - makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true), - makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false), - ] - - let plan = BrowserImportPlanResolver.defaultPlan( - selectedSourceProfiles: sourceProfiles, - destinationProfiles: [defaultProfile], - preferredSingleDestinationProfileID: defaultProfile.id - ) - - XCTAssertEqual(plan.mode, .separateProfiles) - XCTAssertEqual(plan.entries.count, 2) - XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]]) - } - - @MainActor - func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() { - let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")! - let sourceProfile = makeSourceProfile( - displayName: "You", - path: "/tmp/browser-import-single", - isDefault: true - ) - - let plan = BrowserImportPlanResolver.defaultPlan( - selectedSourceProfiles: [sourceProfile], - destinationProfiles: [], - preferredSingleDestinationProfileID: defaultProfileID - ) - - XCTAssertEqual(plan.mode, .singleDestination) - XCTAssertEqual(plan.entries.count, 1) - XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"]) - } - - @MainActor - func testSeparatePlanReusesExistingSameNamedDestinationProfiles() { - let workID = UUID() - let destinationProfiles = [ - BrowserProfileDefinition( - id: workID, - displayName: "You", - createdAt: .distantPast, - isBuiltInDefault: false - ) - ] - let sourceProfiles = [ - makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true) - ] - - let plan = BrowserImportPlanResolver.separateProfilesPlan( - selectedSourceProfiles: sourceProfiles, - destinationProfiles: destinationProfiles - ) - - XCTAssertEqual(plan.entries.count, 1) - XCTAssertEqual(plan.entries[0].destination, .existing(workID)) - } - - @MainActor - func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() { - let sourceProfiles = [ - makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true), - makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false), - ] - - let plan = BrowserImportPlanResolver.separateProfilesPlan( - selectedSourceProfiles: sourceProfiles, - destinationProfiles: [] - ) - - XCTAssertEqual(plan.entries.count, 2) - XCTAssertEqual(plan.entries[0].destination, .createNamed("Work")) - XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)")) - } - - func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() { - let presentation = BrowserImportStep3Presentation( - plan: BrowserImportExecutionPlan( - mode: .separateProfiles, - entries: [ - BrowserImportExecutionEntry( - sourceProfiles: [ - makeSourceProfile( - displayName: "You", - path: "/tmp/browser-import-presentation-separate", - isDefault: true - ) - ], - destination: .createNamed("You") - ) - ] - ) - ) - - XCTAssertTrue(presentation.showsSeparateRows) - XCTAssertFalse(presentation.showsSingleDestinationPicker) - } - - func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() { - let presentation = BrowserImportStep3Presentation( - plan: BrowserImportExecutionPlan( - mode: .mergeIntoOne, - entries: [] - ) - ) - - XCTAssertFalse(presentation.showsSeparateRows) - XCTAssertTrue(presentation.showsSingleDestinationPicker) - } - - func testSourceProfilesPresentationShrinksListForSmallProfileCounts() { - let presentation = BrowserImportSourceProfilesPresentation(profileCount: 2) - - XCTAssertEqual(presentation.scrollHeight, 76) - XCTAssertTrue(presentation.showsHelpText) - } - - func testSourceProfilesPresentationCapsListHeightAndHidesHelpForSingleProfile() { - let singleProfilePresentation = BrowserImportSourceProfilesPresentation(profileCount: 1) - let manyProfilesPresentation = BrowserImportSourceProfilesPresentation(profileCount: 9) - - XCTAssertEqual(singleProfilePresentation.scrollHeight, 76) - XCTAssertFalse(singleProfilePresentation.showsHelpText) - XCTAssertEqual(manyProfilesPresentation.scrollHeight, 144) - XCTAssertTrue(manyProfilesPresentation.showsHelpText) - } - - func testBrowserImportHintSettingsDefaultToToolbarChip() throws { - let suiteName = "BrowserImportHintDefaults-\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let presentation = BrowserImportHintSettings.presentation(defaults: defaults) - - XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) - XCTAssertEqual(presentation.settingsStatus, .visible) - } - - func testBrowserImportHintPresentationHidesBlankTabHintWhenDismissed() { - let presentation = BrowserImportHintPresentation( - showOnBlankTabs: true, - isDismissed: true - ) - - XCTAssertEqual(presentation.blankTabPlacement, .hidden) - XCTAssertEqual(presentation.settingsStatus, .hidden) - } - - func testBrowserImportHintPresentationUsesToolbarChipWhenEnabled() { - let presentation = BrowserImportHintPresentation( - showOnBlankTabs: true, - isDismissed: false - ) - - XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip) - XCTAssertEqual(presentation.settingsStatus, .visible) - } - - @MainActor - func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws { - let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let store = BrowserProfileStore(defaults: defaults) - let plan = BrowserImportExecutionPlan( - mode: .separateProfiles, - entries: [ - BrowserImportExecutionEntry( - sourceProfiles: [ - makeSourceProfile( - displayName: "You", - path: "/tmp/browser-import-realize-create", - isDefault: true - ) - ], - destination: .createNamed("You") - ) - ] - ) - - let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store) - - XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"]) - XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"]) - } - - @MainActor - func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws { - let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - defer { defaults.removePersistentDomain(forName: suiteName) } - - let store = BrowserProfileStore(defaults: defaults) - let existing = try XCTUnwrap(store.createProfile(named: "You")) - let plan = BrowserImportExecutionPlan( - mode: .separateProfiles, - entries: [ - BrowserImportExecutionEntry( - sourceProfiles: [ - makeSourceProfile( - displayName: "You", - path: "/tmp/browser-import-realize-existing", - isDefault: true - ) - ], - destination: .existing(existing.id) - ) - ] - ) - - let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store) - - XCTAssertTrue(realized.createdProfiles.isEmpty) - XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id) - } - - func testAggregateOutcomeIncludesOneMappingLinePerDestination() { - let outcome = BrowserImportOutcome( - browserName: "Helium", - scope: .cookiesAndHistory, - domainFilters: [], - createdDestinationProfileNames: ["You", "austin"], - entries: [ - BrowserImportOutcomeEntry( - sourceProfileNames: ["You"], - destinationProfileName: "You", - importedCookies: 10, - skippedCookies: 0, - importedHistoryEntries: 20, - warnings: [] - ), - BrowserImportOutcomeEntry( - sourceProfileNames: ["austin"], - destinationProfileName: "austin", - importedCookies: 5, - skippedCookies: 1, - importedHistoryEntries: 9, - warnings: [] - ), - ], - warnings: [] - ) - - let lines = BrowserImportOutcomeFormatter.lines(for: outcome) - - XCTAssertTrue(lines.contains("You -> You")) - XCTAssertTrue(lines.contains("austin -> austin")) - XCTAssertTrue(lines.contains("Created Programa profiles: You, austin")) - } - - func testDomainFiltersCanonicalizeAndDeduplicateEquivalentIDNForms() { - let filters = BrowserDataImporter.parseDomainFilters( - " bücher.de, xn--bcher-kva.de; *.BÜCHER.DE " - ) - - XCTAssertEqual( - filters, - ["bücher.de"], - "Unicode and Punycode spellings of one domain must produce one canonical import filter" - ) - } - - func testDomainMatchingTreatsUnicodeAndPunycodeHostsAsEquivalent() { - XCTAssertTrue( - BrowserDataImporter.domainMatches( - host: "xn--bcher-kva.de", - filters: ["bücher.de"] - ) - ) - XCTAssertTrue( - BrowserDataImporter.domainMatches( - host: "bücher.de", - filters: ["xn--bcher-kva.de"] - ) - ) - XCTAssertTrue( - BrowserDataImporter.domainMatches( - host: "shop.xn--bcher-kva.de", - filters: ["bücher.de"] - ), - "An IDN filter must include true subdomains after canonicalization" - ) - } - - func testDomainMatchingAllowsAnyHostWhenFiltersAreEmpty() { - XCTAssertTrue( - BrowserDataImporter.domainMatches( - host: "anything.example", - filters: [] - ), - "An empty domain filter list must preserve the import-all contract" - ) - } - - func testDomainMatchingRejectsLookalikesAndUnrelatedIDNs() { - XCTAssertFalse( - BrowserDataImporter.domainMatches( - host: "evilxn--bcher-kva.de", - filters: ["bücher.de"] - ), - "Suffix matching must require a dot boundary before the canonical IDN" - ) - XCTAssertFalse( - BrowserDataImporter.domainMatches( - host: "kücher.de", - filters: ["bücher.de"] - ), - "Canonicalization must not collapse distinct Unicode domains" - ) - XCTAssertFalse( - BrowserDataImporter.domainMatches( - host: "example.com", - filters: ["example.com/path"] - ), - "A malformed domain-only filter must not be parsed as a URL and broadened to its host" - ) - XCTAssertFalse( - BrowserDataImporter.domainMatches( - host: "example.com", - filters: ["example.com:443"] - ), - "A domain-only filter containing a port must preserve fail-closed comparison behavior" - ) - } - - @MainActor - func testImportWizardCanBeConstructedForSettingsChoosePath() { - let destinationProfiles = [ - BrowserProfileDefinition( - id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!, - displayName: "Default", - createdAt: .distantPast, - isBuiltInDefault: true - ) - ] - let browser = makeInstalledBrowserCandidate( - descriptorID: "google-chrome", - displayName: "Chrome", - profiles: [ - makeSourceProfile(displayName: "Default", path: "/tmp/browser-import-chrome-default", isDefault: true), - makeSourceProfile(displayName: "Profile 1", path: "/tmp/browser-import-chrome-profile-1", isDefault: false), - ] - ) - - let window = BrowserDataImportCoordinator.shared.debugMakeImportWizardWindow( - browsers: [browser], - destinationProfiles: destinationProfiles, - defaultDestinationProfileID: destinationProfiles[0].id - ) - defer { - window.orderOut(nil) - window.close() - } - - XCTAssertEqual(window.title, "Import Browser Data") - XCTAssertNotNil(window.contentView) - } - - private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile { - InstalledBrowserProfile( - displayName: displayName, - rootURL: URL(fileURLWithPath: path, isDirectory: true), - isDefault: isDefault - ) - } - - private func makeInstalledBrowserCandidate( - descriptorID: String, - displayName: String, - profiles: [InstalledBrowserProfile] - ) -> InstalledBrowserCandidate { - let descriptor = try! XCTUnwrap(InstalledBrowserDetector.allBrowserDescriptors.first(where: { $0.id == descriptorID })) - return InstalledBrowserCandidate( - descriptor: BrowserImportBrowserDescriptor( - id: descriptor.id, - displayName: displayName, - family: descriptor.family, - tier: descriptor.tier, - bundleIdentifiers: descriptor.bundleIdentifiers, - appNames: descriptor.appNames, - dataRootRelativePaths: descriptor.dataRootRelativePaths, - dataArtifactRelativePaths: descriptor.dataArtifactRelativePaths, - supportsDataOnlyDetection: descriptor.supportsDataOnlyDetection - ), - resolvedFamily: descriptor.family, - homeDirectoryURL: URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true), - appURL: nil, - dataRootURL: URL(fileURLWithPath: "/tmp/browser-import-\(descriptorID)", isDirectory: true), - profiles: profiles, - detectionSignals: ["test"], - detectionScore: 1 - ) - } -} diff --git a/programaTests/BrowserPanelTests.swift b/programaTests/BrowserPanelTests.swift index 6a1fc99f..8d8dd619 100644 --- a/programaTests/BrowserPanelTests.swift +++ b/programaTests/BrowserPanelTests.swift @@ -247,197 +247,6 @@ final class BrowserPanelAddressBarFocusRequestTests: XCTestCase { } -@MainActor -final class BrowserPanelReactGrabBridgeTests: XCTestCase { - @MainActor - func testExplicitWebViewFocusDoesNotSuppressOmnibarAutofocusWhenFocusFails() { - let panel = BrowserPanel(workspaceId: UUID()) - - XCTAssertFalse(panel.shouldSuppressOmnibarAutofocus()) - XCTAssertFalse(panel.requestExplicitWebViewFocus()) - XCTAssertFalse(panel.shouldSuppressOmnibarAutofocus()) - } - - func testCopySuccessPostsPastebackNotificationAndClearsPendingTarget() throws { - let workspaceId = UUID() - let terminalId = UUID() - let panel = BrowserPanel(workspaceId: workspaceId) - let browserId = panel.id - let expectation = expectation(description: "react grab pasteback notification") - - let observer = NotificationCenter.default.addObserver( - forName: .reactGrabDidCopySelection, - object: nil, - queue: .main - ) { notification in - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, workspaceId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID, browserId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, terminalId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String, "<button>Save</button>") - expectation.fulfill() - } - defer { NotificationCenter.default.removeObserver(observer) } - - panel.armReactGrabRoundTrip(returnTo: terminalId) - XCTAssertEqual(panel.pendingReactGrabReturnTargetPanelId, terminalId) - let token = try XCTUnwrap(panel.pendingReactGrabRoundTripToken) - - panel.handleReactGrabBridgeMessage(.copySuccess(content: "<button>Save</button>", token: token)) - - wait(for: [expectation], timeout: 1.0) - XCTAssertNil(panel.pendingReactGrabReturnTargetPanelId) - XCTAssertNil(panel.pendingReactGrabRoundTripToken) - } - - func testInactiveStateKeepsPendingTargetUntilCopySuccess() throws { - let workspaceId = UUID() - let terminalId = UUID() - let panel = BrowserPanel(workspaceId: workspaceId) - let browserId = panel.id - let expectation = expectation(description: "react grab pasteback notification after deactivate") - - let observer = NotificationCenter.default.addObserver( - forName: .reactGrabDidCopySelection, - object: nil, - queue: .main - ) { notification in - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, workspaceId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID, browserId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, terminalId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String, "<button>Save</button>") - expectation.fulfill() - } - defer { NotificationCenter.default.removeObserver(observer) } - - panel.armReactGrabRoundTrip(returnTo: terminalId) - XCTAssertEqual(panel.pendingReactGrabReturnTargetPanelId, terminalId) - let token = try XCTUnwrap(panel.pendingReactGrabRoundTripToken) - - panel.handleReactGrabBridgeMessage(.stateChange(isActive: false)) - - XCTAssertEqual(panel.pendingReactGrabReturnTargetPanelId, terminalId) - XCTAssertFalse(panel.isReactGrabActive) - - panel.handleReactGrabBridgeMessage(.copySuccess(content: "<button>Save</button>", token: token)) - - wait(for: [expectation], timeout: 1.0) - XCTAssertNil(panel.pendingReactGrabReturnTargetPanelId) - XCTAssertNil(panel.pendingReactGrabRoundTripToken) - } - - func testResetStateCanPreservePendingTargetUntilCopySuccess() throws { - let workspaceId = UUID() - let terminalId = UUID() - let panel = BrowserPanel(workspaceId: workspaceId) - let browserId = panel.id - let expectation = expectation(description: "react grab pasteback notification after reset") - - let observer = NotificationCenter.default.addObserver( - forName: .reactGrabDidCopySelection, - object: nil, - queue: .main - ) { notification in - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, workspaceId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID, browserId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, terminalId) - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String, "<button>Save</button>") - expectation.fulfill() - } - defer { NotificationCenter.default.removeObserver(observer) } - - panel.armReactGrabRoundTrip(returnTo: terminalId) - panel.handleReactGrabBridgeMessage(.stateChange(isActive: true)) - let token = try XCTUnwrap(panel.pendingReactGrabRoundTripToken) - - panel.resetReactGrabState( - preserveRoundTrip: true, - reason: "test.navigation" - ) - - XCTAssertFalse(panel.isReactGrabActive) - XCTAssertEqual(panel.pendingReactGrabReturnTargetPanelId, terminalId) - - panel.handleReactGrabBridgeMessage(.copySuccess(content: "<button>Save</button>", token: token)) - - wait(for: [expectation], timeout: 1.0) - XCTAssertNil(panel.pendingReactGrabReturnTargetPanelId) - XCTAssertNil(panel.pendingReactGrabRoundTripToken) - } - - func testMismatchedCopyTokenDropsPastebackAndClearsPendingTarget() { - let terminalId = UUID() - let panel = BrowserPanel(workspaceId: UUID()) - let invertedExpectation = expectation(description: "react grab pasteback notification") - invertedExpectation.isInverted = true - - let observer = NotificationCenter.default.addObserver( - forName: .reactGrabDidCopySelection, - object: nil, - queue: .main - ) { _ in - invertedExpectation.fulfill() - } - defer { NotificationCenter.default.removeObserver(observer) } - - panel.armReactGrabRoundTrip(returnTo: terminalId) - XCTAssertEqual(panel.pendingReactGrabReturnTargetPanelId, terminalId) - XCTAssertNotNil(panel.pendingReactGrabRoundTripToken) - - panel.handleReactGrabBridgeMessage(.copySuccess(content: "<button>Save</button>", token: nil)) - - wait(for: [invertedExpectation], timeout: 0.1) - XCTAssertNil(panel.pendingReactGrabReturnTargetPanelId) - XCTAssertNil(panel.pendingReactGrabRoundTripToken) - } - - func testCopySuccessStripsDangerousInvisibleScalarsBeforePastebackNotification() throws { - let workspaceId = UUID() - let terminalId = UUID() - let panel = BrowserPanel(workspaceId: workspaceId) - let expectation = expectation(description: "react grab pasteback notification") - let rawContent = "<button>Sa\u{202E}v\u{200B}e</button>\u{2069}\n" - - let observer = NotificationCenter.default.addObserver( - forName: .reactGrabDidCopySelection, - object: nil, - queue: .main - ) { notification in - XCTAssertEqual(notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String, "<button>Save</button>\n") - expectation.fulfill() - } - defer { NotificationCenter.default.removeObserver(observer) } - - panel.armReactGrabRoundTrip(returnTo: terminalId) - let token = try XCTUnwrap(panel.pendingReactGrabRoundTripToken) - - panel.handleReactGrabBridgeMessage(.copySuccess(content: rawContent, token: token)) - - wait(for: [expectation], timeout: 1.0) - } - - func testEnsureReactGrabActiveRefreshesBridgeSessionTokenWhenAlreadyActive() async throws { - let panel = BrowserPanel(workspaceId: UUID()) - - _ = try await panel.evaluateJavaScript( - """ - window['\(panel.reactGrabBridgeSessionUpdaterName)'] = function(token) { - window.__cmuxTestRoundTripToken = token; - return true; - }; - true; - """ - ) - - panel.handleReactGrabBridgeMessage(.stateChange(isActive: true)) - panel.armReactGrabRoundTrip(returnTo: UUID()) - let token = try XCTUnwrap(panel.pendingReactGrabRoundTripToken) - - await panel.ensureReactGrabActive() - - let refreshedToken = try await panel.evaluateJavaScript("window.__cmuxTestRoundTripToken") as? String - XCTAssertEqual(refreshedToken, token) - } -} @MainActor diff --git a/programaTests/GhosttyConfigTests.swift b/programaTests/GhosttyConfigTests.swift index c858f24b..030e7628 100644 --- a/programaTests/GhosttyConfigTests.swift +++ b/programaTests/GhosttyConfigTests.swift @@ -913,243 +913,6 @@ final class WindowTransparencyDecisionTests: XCTestCase { } } -final class WorkspaceRemoteDaemonManifestTests: XCTestCase { - func testParsesEmbeddedRemoteDaemonManifestJSON() throws { - let manifestJSON = """ - { - "schemaVersion": 1, - "appVersion": "0.62.0", - "releaseTag": "v0.62.0", - "releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0", - "checksumsAssetName": "programad-remote-checksums.txt", - "checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/programad-remote-checksums.txt", - "entries": [ - { - "goOS": "linux", - "goArch": "amd64", - "assetName": "programad-remote-linux-amd64", - "downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/programad-remote-linux-amd64", - "sha256": "abc123" - } - ] - } - """ - - let manifest = Workspace.remoteDaemonManifest(from: [ - Workspace.remoteDaemonManifestInfoKey: manifestJSON, - ]) - - XCTAssertEqual(manifest?.releaseTag, "v0.62.0") - XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "programad-remote-linux-amd64") - } - - func testRemoteDaemonCachePathIsVersionedByPlatform() throws { - let url = try Workspace.remoteDaemonCachedBinaryURL( - version: "0.62.0", - goOS: "linux", - goArch: "arm64" - ) - - XCTAssertTrue(url.path.contains("/Application Support/programa/remote-daemons/0.62.0/linux-arm64/")) - XCTAssertEqual(url.lastPathComponent, "programad-remote") - } -} - -final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { - @MainActor - func testBrowserEmittedLoopbackAliasRoutesToRemoteLoopbackAndEnablesHeaderRewriting() throws { - let localhostURL = try XCTUnwrap(URL(string: "http://localhost:3000/demo")) - let browserURL = try XCTUnwrap(BrowserPanel.remoteProxyLoopbackAliasURL(for: localhostURL)) - let emittedHost = try XCTUnwrap(browserURL.host) - let route = workspaceRemoteLoopbackProxyRoute(for: emittedHost) - - XCTAssertEqual(route.targetHost, "127.0.0.1") - let rewriteAlias = try XCTUnwrap( - route.rewriteAliasHost, - "The exact alias emitted by BrowserPanel must enable proxy request/response rewriting" - ) - - let original = Data( - ( - "GET /demo HTTP/1.1\r\n" + - "Host: \(emittedHost):3000\r\n" + - "Origin: http://\(emittedHost):3000\r\n" + - "\r\n" - ).utf8 - ) - let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: original, - aliasHost: rewriteAlias - ) - let text = String(decoding: rewritten, as: UTF8.self) - XCTAssertTrue(text.contains("Host: localhost:3000")) - XCTAssertTrue(text.contains("Origin: http://localhost:3000")) - } - - func testLegacyProgramaLoopbackAliasStillRoutesAndRewritesHeaders() throws { - let legacyAlias = "programa-loopback.localtest.me" - let route = workspaceRemoteLoopbackProxyRoute(for: legacyAlias) - - XCTAssertEqual(route.targetHost, "127.0.0.1") - XCTAssertEqual(route.rewriteAliasHost, legacyAlias) - - let original = Data( - ( - "GET /legacy HTTP/1.1\r\n" + - "Host: \(legacyAlias):3000\r\n" + - "\r\n" - ).utf8 - ) - let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: original, - aliasHost: try XCTUnwrap(route.rewriteAliasHost) - ) - XCTAssertTrue(String(decoding: rewritten, as: UTF8.self).contains("Host: localhost:3000")) - } - - func testRewritesLoopbackAliasHostHeadersToLocalhost() { - let original = Data( - ( - "GET /demo HTTP/1.1\r\n" + - "Host: cmux-loopback.localtest.me:3000\r\n" + - "Origin: http://cmux-loopback.localtest.me:3000\r\n" + - "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + - "\r\n" - ).utf8 - ) - - let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: original, - aliasHost: "cmux-loopback.localtest.me" - ) - - let text = String(decoding: rewritten, as: UTF8.self) - XCTAssertTrue(text.contains("Host: localhost:3000")) - XCTAssertTrue(text.contains("Origin: http://localhost:3000")) - XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) - XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) - } - - func testRewritesAbsoluteFormRequestLineForLoopbackAlias() { - let original = Data( - ( - "GET http://cmux-loopback.localtest.me:3000/demo HTTP/1.1\r\n" + - "Host: cmux-loopback.localtest.me:3000\r\n" + - "\r\n" - ).utf8 - ) - - let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: original, - aliasHost: "cmux-loopback.localtest.me" - ) - - let text = String(decoding: rewritten, as: UTF8.self) - XCTAssertTrue(text.hasPrefix("GET http://localhost:3000/demo HTTP/1.1\r\n")) - XCTAssertTrue(text.contains("Host: localhost:3000")) - } - - func testLeavesNonHTTPPayloadUntouched() { - let original = Data([0x16, 0x03, 0x01, 0x00, 0x2a, 0x01, 0x00]) - let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( - data: original, - aliasHost: "cmux-loopback.localtest.me" - ) - XCTAssertEqual(rewritten, original) - } - - func testBuffersSplitLoopbackAliasHeadersUntilFullRequestArrives() { - var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter( - aliasHost: "cmux-loopback.localtest.me" - ) - - let firstChunk = Data( - ( - "GET /demo HTTP/1.1\r\n" + - "Host: cmux-loop" - ).utf8 - ) - let secondChunk = Data( - ( - "back.localtest.me:3000\r\n" + - "Origin: http://cmux-loopback.localtest.me:3000\r\n" + - "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + - "\r\n" + - "body=1" - ).utf8 - ) - - let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false) - let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: false) - - XCTAssertTrue(firstOutput.isEmpty) - - let text = String(decoding: secondOutput, as: UTF8.self) - XCTAssertTrue(text.contains("Host: localhost:3000")) - XCTAssertTrue(text.contains("Origin: http://localhost:3000")) - XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) - XCTAssertTrue(text.hasSuffix("\r\n\r\nbody=1")) - XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) - } - - func testFlushesBufferedLoopbackAliasHeadersOnEOFWhenHeadersRemainIncomplete() { - var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter( - aliasHost: "cmux-loopback.localtest.me" - ) - - let firstChunk = Data( - ( - "GET /demo HTTP/1.1\r\n" + - "Host: cmux-loop" - ).utf8 - ) - let secondChunk = Data( - ( - "back.localtest.me:3000\r\n" + - "Origin: http://cmux-loopback.localtest.me:3000\r\n" + - "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + - "body=1" - ).utf8 - ) - - let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false) - let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: true) - let thirdOutput = streamRewriter.rewriteNextChunk(Data(), eof: true) - - XCTAssertTrue(firstOutput.isEmpty) - - let text = String(decoding: secondOutput, as: UTF8.self) - XCTAssertTrue(text.contains("Host: localhost:3000")) - XCTAssertTrue(text.contains("Origin: http://localhost:3000")) - XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) - XCTAssertTrue(text.hasSuffix("\r\nbody=1")) - XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) - XCTAssertTrue(thirdOutput.isEmpty) - } - - func testRewritesLoopbackResponseHeadersBackToAlias() { - let original = Data( - ( - "HTTP/1.1 302 Found\r\n" + - "Location: http://localhost:3000/login\r\n" + - "Access-Control-Allow-Origin: http://localhost:3000\r\n" + - "Set-Cookie: sid=1; Domain=localhost; Path=/\r\n" + - "\r\n" - ).utf8 - ) - - let rewritten = RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( - data: original, - aliasHost: "cmux-loopback.localtest.me" - ) - - let text = String(decoding: rewritten, as: UTF8.self) - XCTAssertTrue(text.contains("Location: http://cmux-loopback.localtest.me:3000/login")) - XCTAssertTrue(text.contains("Access-Control-Allow-Origin: http://cmux-loopback.localtest.me:3000")) - XCTAssertTrue(text.contains("Set-Cookie: sid=1; Domain=cmux-loopback.localtest.me; Path=/")) - } -} - final class GhosttyTerminalStartupEnvironmentTests: XCTestCase { func testPortRangeAssignmentFallsBackWithoutOverflowForMalformedSettings() { let assignment = ProgramaPortRangePolicy.assignment( @@ -1299,27 +1062,7 @@ final class GhosttyTerminalStartupEnvironmentTests: XCTestCase { @MainActor final class BrowserPanelPopupContextTests: XCTestCase { func testFloatingPopupInheritsOpenerBrowserContext() throws { - let panel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) - let popupWebView = try XCTUnwrap( - panel.createFloatingPopup( - configuration: WKWebViewConfiguration(), - windowFeatures: WKWindowFeatures() - ) - ) - defer { popupWebView.window?.close() } - - XCTAssertTrue( - popupWebView.configuration.websiteDataStore === panel.webView.configuration.websiteDataStore - ) - } - - func testFloatingPopupInheritsRemoteWorkspaceWebsiteDataStore() throws { - let remoteWorkspaceId = UUID() - let panel = BrowserPanel( - workspaceId: remoteWorkspaceId, - isRemoteWorkspace: true, - remoteWebsiteDataStoreIdentifier: remoteWorkspaceId - ) + let panel = BrowserPanel(workspaceId: UUID()) let popupWebView = try XCTUnwrap( panel.createFloatingPopup( configuration: WKWebViewConfiguration(), @@ -1331,298 +1074,8 @@ final class BrowserPanelPopupContextTests: XCTestCase { XCTAssertTrue( popupWebView.configuration.websiteDataStore === panel.webView.configuration.websiteDataStore ) - XCTAssertFalse(popupWebView.configuration.websiteDataStore === WKWebsiteDataStore.default()) - } -} - -@MainActor -final class BrowserPanelRemoteStoreTests: XCTestCase { - private var previousProfileStore: BrowserProfileStore? - private var isolatedProfileStoreSuiteName: String? - - override func setUp() { - super.setUp() - // BrowserProfileStore.shared is a process-wide @MainActor singleton that - // persists `browserProfiles.lastUsed` into the real UserDefaults.standard. - // Other tests (e.g. BrowserPanelTests createProfile()/switchToProfile()) call - // noteUsed() on that same shared instance, which leaves a non-default - // lastUsedProfileID behind — on this run and even across dev-machine runs, - // since UserDefaults.standard persists to disk. That pollution makes - // BrowserPanel(workspaceId:, isRemoteWorkspace: false) resolve a profile-scoped - // WKWebsiteDataStore instead of WKWebsiteDataStore.default(), which these tests - // assert against. Isolate by swapping in a fresh store backed by an ephemeral, - // uniquely-named UserDefaults suite for the duration of each test, then restore - // the previous shared instance in tearDown so other test classes are unaffected. - let suiteName = "com.darkroom.programa.tests.browserProfileStore.\(UUID().uuidString)" - isolatedProfileStoreSuiteName = suiteName - let isolatedDefaults = UserDefaults(suiteName: suiteName)! - isolatedDefaults.removePersistentDomain(forName: suiteName) - previousProfileStore = BrowserProfileStore.replaceSharedForTesting( - BrowserProfileStore(defaults: isolatedDefaults) - ) - } - - override func tearDown() { - if let previousProfileStore { - BrowserProfileStore.replaceSharedForTesting(previousProfileStore) - } - if let isolatedProfileStoreSuiteName { - UserDefaults(suiteName: isolatedProfileStoreSuiteName)?.removePersistentDomain( - forName: isolatedProfileStoreSuiteName - ) - } - previousProfileStore = nil - isolatedProfileStoreSuiteName = nil - super.tearDown() - } - - func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() { - let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) - let remoteWorkspaceId = UUID() - let firstRemotePanel = BrowserPanel( - workspaceId: remoteWorkspaceId, - isRemoteWorkspace: true, - remoteWebsiteDataStoreIdentifier: remoteWorkspaceId - ) - let secondRemotePanel = BrowserPanel( - workspaceId: remoteWorkspaceId, - isRemoteWorkspace: true, - remoteWebsiteDataStoreIdentifier: remoteWorkspaceId - ) - - XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) - XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) - XCTAssertTrue( - firstRemotePanel.webView.configuration.websiteDataStore === - secondRemotePanel.webView.configuration.websiteDataStore - ) - } - - func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() { - let remoteWorkspaceId = UUID() - let url = URL(string: "http://localhost:3000/demo")! - let panel = BrowserPanel( - workspaceId: remoteWorkspaceId, - initialURL: url, - isRemoteWorkspace: true, - remoteWebsiteDataStoreIdentifier: remoteWorkspaceId - ) - - XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) - XCTAssertNil(panel.webView.url) - - panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) - - let deadline = Date().addingTimeInterval(1.0) - while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} - - XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) - XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me") - } - - func testRemoteWorkspaceKeepsHTTPSLoopbackUnaliased() { - let remoteWorkspaceId = UUID() - let url = URL(string: "https://localhost:3443/demo")! - let panel = BrowserPanel( - workspaceId: remoteWorkspaceId, - initialURL: url, - isRemoteWorkspace: true, - remoteWebsiteDataStoreIdentifier: remoteWorkspaceId - ) - - XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) - XCTAssertNil(panel.webView.url) - - panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) - - let deadline = Date().addingTimeInterval(1.0) - while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} - - XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) - XCTAssertEqual(panel.webView.url?.host, "localhost") - } - - func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws { - let source = Workspace() - let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) - let sourceBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) - let localStore = sourceBrowser.webView.configuration.websiteDataStore - XCTAssertTrue(localStore === WKWebsiteDataStore.default()) - - let destination = Workspace() - destination.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 22, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64001, - relayID: "relay-store-dest", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/programa-store-dest.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - let destinationBrowser = try XCTUnwrap(destination.newBrowserSurface(inPane: destinationPaneId, focus: false)) - let destinationStore = destinationBrowser.webView.configuration.websiteDataStore - XCTAssertFalse(destinationStore === WKWebsiteDataStore.default()) - - let detached = try XCTUnwrap(source.detachSurface(panelId: sourceBrowser.id)) - let attachedPanelId = try XCTUnwrap( - destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) - ) - let movedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel) - - XCTAssertTrue(movedBrowser.webView.configuration.websiteDataStore === destinationStore) - XCTAssertFalse(movedBrowser.webView.configuration.websiteDataStore === localStore) - } - - func testBrowserMoveOutOfRemoteWorkspaceRestoresDefaultWebsiteDataStore() throws { - let source = Workspace() - source.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 22, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64002, - relayID: "relay-store-source", - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-store-source.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) - let movedBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) - let remainingRemoteBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) - let remoteStore = remainingRemoteBrowser.webView.configuration.websiteDataStore - XCTAssertFalse(remoteStore === WKWebsiteDataStore.default()) - - let destination = Workspace() - let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - let detached = try XCTUnwrap(source.detachSurface(panelId: movedBrowser.id)) - let attachedPanelId = try XCTUnwrap( - destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) - ) - let attachedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel) - - XCTAssertTrue(attachedBrowser.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) - XCTAssertTrue(remainingRemoteBrowser.webView.configuration.websiteDataStore === remoteStore) - XCTAssertFalse(remainingRemoteBrowser.webView.configuration.websiteDataStore === attachedBrowser.webView.configuration.websiteDataStore) - } - - func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws { - let workspace = Workspace() - let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId) - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64000, - relayID: "relay-test", - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/programa-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - workspace.configureRemoteConnection(configuration, autoConnect: false) - _ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false) - - workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort) - - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - - _ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) - - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) - } -} - -final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { - func testProxyBrokerTransportKeyIgnoresControlPath() { - let first = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 22, - identityFile: "~/.ssh/id_ed25519", - sshOptions: [ - "Compression=yes", - "ControlMaster=auto", - "ControlPath=/tmp/programa-ssh-501-64000-%C", - ], - localProxyPort: 9000, - relayPort: 64000, - relayID: "relay-a", - relayToken: "token-a", - localSocketPath: "/tmp/programa-a.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let second = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 22, - identityFile: "~/.ssh/id_ed25519", - sshOptions: [ - "Compression=yes", - "ControlMaster=auto", - "ControlPath=/tmp/programa-ssh-501-64001-%C", - ], - localProxyPort: 9000, - relayPort: 64001, - relayID: "relay-b", - relayToken: "token-b", - localSocketPath: "/tmp/programa-b.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey) - } -} - -final class WorkspaceRemoteSSHCleanupTests: XCTestCase { - func testOrphanedCMUXRemoteSSHPIDsMatchesOnlyParentOneRelayAndDaemonTransports() { - let psOutput = """ - 101 1 /usr/bin/ssh -N -T -S none -o ControlPath=/tmp/programa-ssh-501-56080-%C -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini - 102 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .programa/bin/programad-remote/0.63.1/darwin-arm64/programad-remote serve --stdio' - 103 999 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini - 104 1 /usr/bin/ssh -tt cmux-macmini - 105 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56082:127.0.0.1:64050 other-host - 106 1 /usr/bin/ssh -T -S none cmux-macmini /bin/sh - """ - - XCTAssertEqual( - WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( - psOutput: psOutput, - destination: "cmux-macmini" - ), - [101, 102] - ) } - func testOrphanedCMUXRemoteSSHPIDsCanRestrictCleanupToSpecificRelayPort() { - let psOutput = """ - 201 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56080:127.0.0.1:64048 cmux-macmini - 202 1 /usr/bin/ssh -N -T -S none -R 127.0.0.1:56081:127.0.0.1:64049 cmux-macmini - 203 1 /usr/bin/ssh -T -S none -o RequestTTY=no cmux-macmini sh -c 'exec .programa/bin/programad-remote/0.63.1/darwin-arm64/programad-remote serve --stdio' - """ - - XCTAssertEqual( - WorkspaceRemoteSessionController.orphanedCMUXRemoteSSHPIDs( - psOutput: psOutput, - destination: "cmux-macmini", - relayPort: 56081 - ), - [202] - ) - } } final class TitlebarDoubleClickPreferenceTests: XCTestCase { @@ -1670,92 +1123,6 @@ final class TitlebarDoubleClickPreferenceTests: XCTestCase { } } -final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { - func testSupportsMultiplePendingCallsResolvedOutOfOrder() { - let registry = WorkspaceRemoteDaemonPendingCallRegistry() - let first = registry.register() - let second = registry.register() - - XCTAssertTrue(registry.resolve(id: second.id, payload: [ - "ok": true, - "result": ["stream_id": "second"], - ])) - - switch registry.wait(for: second, timeout: 0.1) { - case .response(let response): - XCTAssertEqual(response["ok"] as? Bool, true) - XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "second") - default: - XCTFail("second pending call should complete independently") - } - - XCTAssertTrue(registry.resolve(id: first.id, payload: [ - "ok": true, - "result": ["stream_id": "first"], - ])) - - switch registry.wait(for: first, timeout: 0.1) { - case .response(let response): - XCTAssertEqual(response["ok"] as? Bool, true) - XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "first") - default: - XCTFail("first pending call should remain pending until its own response arrives") - } - } - - func testFailAllSignalsEveryPendingCall() { - let registry = WorkspaceRemoteDaemonPendingCallRegistry() - let first = registry.register() - let second = registry.register() - - registry.failAll("daemon transport stopped") - - switch registry.wait(for: first, timeout: 0.1) { - case .failure(let message): - XCTAssertEqual(message, "daemon transport stopped") - default: - XCTFail("first pending call should receive shared failure") - } - - switch registry.wait(for: second, timeout: 0.1) { - case .failure(let message): - XCTAssertEqual(message, "daemon transport stopped") - default: - XCTFail("second pending call should receive shared failure") - } - } -} - -final class WorkspaceRemoteProxySessionQueueProviderTests: XCTestCase { - func testStalledProxySessionDoesNotBlockSecondAcceptedSession() { - let tunnelQueue = DispatchQueue(label: "programa.tests.remote-proxy.tunnel") - let provider = WorkspaceRemoteProxySessionQueueProvider(tunnelQueue: tunnelQueue) - let firstSessionQueue = provider.queue(for: UUID()) - let secondSessionQueue = provider.queue(for: UUID()) - let firstStarted = DispatchSemaphore(value: 0) - let releaseFirst = DispatchSemaphore(value: 0) - let secondServed = DispatchSemaphore(value: 0) - - firstSessionQueue.async { - firstStarted.signal() - _ = releaseFirst.wait(timeout: .now() + 2) - } - XCTAssertEqual(firstStarted.wait(timeout: .now() + 1), .success) - - secondSessionQueue.async { - secondServed.signal() - } - let secondResult = secondServed.wait(timeout: .now() + 0.2) - releaseFirst.signal() - - XCTAssertEqual( - secondResult, - .success, - "A stalled proxy connection must not occupy the executor used to serve another accepted connection" - ) - } -} - final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() @@ -4446,244 +3813,6 @@ final class FishShellIntegrationHandoffTests: XCTestCase { } } -final class BrowserInstallDetectorTests: XCTestCase { - func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws { - let home = makeTemporaryHome() - defer { try? FileManager.default.removeItem(at: home) } - - try createFile( - at: home - .appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"), - contents: Data() - ) - try createFile( - at: home - .appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"), - contents: Data() - ) - - let detected = InstalledBrowserDetector.detectInstalledBrowsers( - homeDirectoryURL: home, - bundleLookup: { bundleIdentifier in - if bundleIdentifier == "com.google.Chrome" { - return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true) - } - return nil - }, - applicationSearchDirectories: [] - ) - - guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else { - XCTFail("Expected Chrome to be detected") - return - } - guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else { - XCTFail("Expected Firefox to be detected from profile data") - return - } - - XCTAssertNotNil(chrome.appURL) - XCTAssertEqual(firefox.profileURLs.count, 1) - XCTAssertNil(firefox.appURL) - } - - func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws { - let home = makeTemporaryHome() - defer { try? FileManager.default.removeItem(at: home) } - - let detected = InstalledBrowserDetector.detectInstalledBrowsers( - homeDirectoryURL: home, - bundleLookup: { _ in nil }, - applicationSearchDirectories: [] - ) - - XCTAssertTrue(detected.isEmpty) - } - - func testUngoogledChromiumRequiresAppSignal() throws { - let home = makeTemporaryHome() - defer { try? FileManager.default.removeItem(at: home) } - - try createFile( - at: home - .appendingPathComponent("Library/Application Support/Chromium/Default/History"), - contents: Data() - ) - - let detected = InstalledBrowserDetector.detectInstalledBrowsers( - homeDirectoryURL: home, - bundleLookup: { _ in nil }, - applicationSearchDirectories: [] - ) - - XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" })) - XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" })) - } - - func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws { - let home = makeTemporaryHome() - defer { try? FileManager.default.removeItem(at: home) } - - let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true) - try createFile( - at: heliumRoot.appendingPathComponent("Default/History"), - contents: Data() - ) - try createFile( - at: heliumRoot.appendingPathComponent("Profile 1/Cookies"), - contents: Data() - ) - try createFile( - at: heliumRoot.appendingPathComponent("Local State"), - contents: Data( - """ - { - "profile": { - "info_cache": { - "Default": { - "name": "Personal" - }, - "Profile 1": { - "name": "Work" - } - } - } - } - """.utf8 - ) - ) - - let detected = InstalledBrowserDetector.detectInstalledBrowsers( - homeDirectoryURL: home, - bundleLookup: { _ in nil }, - applicationSearchDirectories: [] - ) - - guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else { - XCTFail("Expected Helium to be detected") - return - } - - XCTAssertEqual(helium.family, .chromium) - XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"]) - XCTAssertEqual( - helium.profiles.map(\.rootURL.lastPathComponent), - ["Default", "Profile 1"] - ) - } - - func testDetectInstalledBrowsersDiscoversSafariProfiles() throws { - let home = makeTemporaryHome() - defer { try? FileManager.default.removeItem(at: home) } - - try createFile( - at: home.appendingPathComponent("Library/Safari/History.db"), - contents: Data() - ) - try createFile( - at: home.appendingPathComponent( - "Library/Safari/Profiles/Work/History.db" - ), - contents: Data() - ) - try createFile( - at: home.appendingPathComponent( - "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db" - ), - contents: Data() - ) - - let detected = InstalledBrowserDetector.detectInstalledBrowsers( - homeDirectoryURL: home, - bundleLookup: { _ in nil }, - applicationSearchDirectories: [] - ) - - guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else { - XCTFail("Expected Safari to be detected") - return - } - - XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"])) - XCTAssertEqual( - safari.profiles - .map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false) } - .sorted(), - [ - home.appendingPathComponent("Library/Safari", isDirectory: true) - .standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), - home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true) - .standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), - home.appendingPathComponent( - "Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel", - isDirectory: true - ).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false), - ].sorted() - ) - } - - private func makeTemporaryHome() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)") - } - - private func createFile(at url: URL, contents: Data) throws { - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - guard FileManager.default.createFile(atPath: url.path, contents: contents) else { - throw CocoaError( - .fileWriteUnknown, - userInfo: [NSFilePathErrorKey: url.path] - ) - } - } -} - -final class BrowserImportScopeTests: XCTestCase { - func testFromSelectionCookiesOnly() { - let scope = BrowserImportScope.fromSelection( - includeCookies: true, - includeHistory: false, - includeAdditionalData: false - ) - XCTAssertEqual(scope, .cookiesOnly) - } - - func testFromSelectionHistoryOnly() { - let scope = BrowserImportScope.fromSelection( - includeCookies: false, - includeHistory: true, - includeAdditionalData: false - ) - XCTAssertEqual(scope, .historyOnly) - } - - func testFromSelectionCookiesAndHistory() { - let scope = BrowserImportScope.fromSelection( - includeCookies: true, - includeHistory: true, - includeAdditionalData: false - ) - XCTAssertEqual(scope, .cookiesAndHistory) - } - - func testFromSelectionEverything() { - let scope = BrowserImportScope.fromSelection( - includeCookies: false, - includeHistory: false, - includeAdditionalData: true - ) - XCTAssertEqual(scope, .everything) - } - - func testFromSelectionRejectsEmptySelection() { - let scope = BrowserImportScope.fromSelection( - includeCookies: false, - includeHistory: false, - includeAdditionalData: false - ) - XCTAssertNil(scope) - } -} - final class SidebarGlassMigrationTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! diff --git a/programaTests/MCPSocketBridgeTests.swift b/programaTests/MCPSocketBridgeTests.swift index 66d8ddf2..c5e244cb 100644 --- a/programaTests/MCPSocketBridgeTests.swift +++ b/programaTests/MCPSocketBridgeTests.swift @@ -25,7 +25,7 @@ import Darwin /// `send(method:params:)` connects to a real Unix domain socket per call (no /// pooling), so these tests stand up a throwaway local listener per case (same /// `bindUnixSocket`/`startMockServer` pattern as -/// `WorkspaceRemoteConnectionTests.swift`) and feed it canned v2 responses -- +/// and feed it canned v2 responses -- /// this is real runtime behavior through the bridge's actual `send()` path, /// not a hand-decoded JSON fixture. final class MCPSocketBridgeTests: XCTestCase { diff --git a/programaTests/MobileBridgeConnectionRegistryTests.swift b/programaTests/MobileBridgeConnectionRegistryTests.swift deleted file mode 100644 index dfa71216..00000000 --- a/programaTests/MobileBridgeConnectionRegistryTests.swift +++ /dev/null @@ -1,1454 +0,0 @@ -import Foundation -import IrohLib -import XCTest - -#if canImport(Programa_DEV) -@testable import Programa_DEV -#elseif canImport(Programa) -@testable import Programa -#endif - -private final class FakeConnection: @unchecked Sendable { - private let lock = NSLock() - private var storedCloseCount = 0 - - var connectionID: ObjectIdentifier { - ObjectIdentifier(self) - } - - var closeAction: MobileBridgeConnectionRegistry.CloseAction { - { [self] in - lock.lock() - storedCloseCount += 1 - lock.unlock() - } - } - - var closeCount: Int { - lock.lock() - defer { lock.unlock() } - return storedCloseCount - } -} - -private final class LockedValue<Value>: @unchecked Sendable { - private let lock = NSLock() - private var value: Value - - init(_ value: Value) { - self.value = value - } - - func withLock<Result>(_ body: (inout Value) -> Result) -> Result { - lock.lock() - defer { lock.unlock() } - return body(&value) - } -} - -private final class FragmentingRecvStream: RecvStream, @unchecked Sendable { - private let lock = NSLock() - private let data: Data - private let maximumBytesPerRead: Int - private var offset = 0 - - init(data: Data, maximumBytesPerRead: Int) { - precondition(maximumBytesPerRead > 0) - self.data = data - self.maximumBytesPerRead = maximumBytesPerRead - super.init(noHandle: RecvStream.NoHandle()) - } - - required init(unsafeFromHandle _: UInt64) { - fatalError("FragmentingRecvStream is test-only and never owns an FFI handle") - } - - override func read(sizeLimit: UInt32) async throws -> Data { - lock.withLock { - guard offset < data.count else { return Data() } - let byteCount = min( - Int(sizeLimit), - maximumBytesPerRead, - data.count - offset - ) - let end = offset + byteCount - let chunk = data.subdata(in: offset ..< end) - offset = end - return chunk - } - } -} - -private final class CancellationBlindOnlineEndpoint: Endpoint, @unchecked Sendable { - private let lock = NSLock() - private let onAcceptNext: () -> Void - private let fakeID: EndpointId - private let fakeAddress: EndpointAddr - private var onlineContinuation: CheckedContinuation<Void, Never>? - private var isOnlineReleased = false - - init(onAcceptNext: @escaping () -> Void) throws { - let id = try EndpointId.fromBytes(bytes: Data([ - 0x52, 0x3c, 0x79, 0x96, 0xba, 0xd7, 0x74, 0x24, - 0xe9, 0x67, 0x86, 0xcf, 0x7a, 0x72, 0x05, 0x11, - 0x53, 0x37, 0xa5, 0xb4, 0x56, 0x5c, 0xd2, 0x55, - 0x06, 0xa0, 0xf2, 0x97, 0xb1, 0x91, 0xa5, 0xea, - ])) - self.onAcceptNext = onAcceptNext - fakeID = id - fakeAddress = EndpointAddr(id: id, relayUrl: nil, addresses: []) - super.init(noHandle: Endpoint.NoHandle()) - } - - required init(unsafeFromHandle _: UInt64) { - fatalError("CancellationBlindOnlineEndpoint is test-only and never owns an FFI handle") - } - - override func online() async { - await withCheckedContinuation { continuation in - let shouldResume = lock.withLock { - guard !isOnlineReleased else { return true } - onlineContinuation = continuation - return false - } - if shouldResume { - continuation.resume() - } - } - } - - override func addr() -> EndpointAddr { - fakeAddress - } - - override func id() -> EndpointId { - fakeID - } - - override func acceptNext() async -> Incoming? { - onAcceptNext() - return nil - } - - override func close() async throws {} - - func releaseOnlineForTest() { - let continuation = lock.withLock { - isOnlineReleased = true - let continuation = onlineContinuation - onlineContinuation = nil - return continuation - } - continuation?.resume() - } -} - -private enum PersistenceFailure: Error { - case expected -} - -private final class ParkingRelayLineSource: @unchecked Sendable { - private let lock = NSLock() - private let onFirstWait: () -> Void - private var didAnnounceWait = false - private var isFinished = false - private var continuations: [CheckedContinuation<Data?, Error>] = [] - - init(onFirstWait: @escaping () -> Void = {}) { - self.onFirstWait = onFirstWait - } - - func nextLine() async throws -> Data? { - try await withCheckedThrowingContinuation { continuation in - let shouldAnnounce: Bool = lock.withLock { - guard !isFinished else { - continuation.resume(returning: nil) - return false - } - continuations.append(continuation) - guard !didAnnounceWait else { return false } - didAnnounceWait = true - return true - } - if shouldAnnounce { - onFirstWait() - } - } - } - - func finish() { - let parkedContinuations: [CheckedContinuation<Data?, Error>] = lock.withLock { - guard !isFinished else { return [] } - isFinished = true - let parkedContinuations = continuations - continuations.removeAll() - return parkedContinuations - } - parkedContinuations.forEach { $0.resume(returning: nil) } - } -} - -private final class ParkingRelayPhoneReader: MobileBridgeRelayLineReading, @unchecked Sendable { - private let source: ParkingRelayLineSource - - init(source: ParkingRelayLineSource) { - self.source = source - } - - func nextLine() async throws -> Data? { - try await source.nextLine() - } -} - -private final class RecordingRelayWriter: MobileBridgeRelayFrameWriting, @unchecked Sendable { - func writeLine(_: Data) async throws {} -} - -private final class ParkingRelayLocalPipe: MobileBridgeRelayLocalPiping, @unchecked Sendable { - private let lock = NSLock() - private let source: ParkingRelayLineSource - private var storedShutdownCount = 0 - - init(source: ParkingRelayLineSource) { - self.source = source - } - - var shutdownCount: Int { - lock.withLock { storedShutdownCount } - } - - func nextLine() async throws -> Data? { - try await source.nextLine() - } - - func send(_: Data) async throws {} - - func shutdownLocalEnd() { - lock.withLock { - storedShutdownCount += 1 - } - source.finish() - } - - /// Test cleanup must be able to release a deliberately cancellation-blind - /// continuation without being counted as production shutdown behavior. - func forceUnblockForTest() { - source.finish() - } -} - -final class MobileBridgeConnectionRegistryTests: XCTestCase { - @discardableResult - private func assertRegistered( - _ result: MobileBridgeConnectionRegistry.RegistrationResult, - file: StaticString = #filePath, - line: UInt = #line - ) -> [MobileBridgeConnectionRegistry.CloseAction] { - guard case .registered(let superseded) = result else { - XCTFail("Expected the current admission ticket to register", file: file, line: line) - return [] - } - return superseded - } - - private func executeRejectedClose( - _ result: MobileBridgeConnectionRegistry.RegistrationResult, - file: StaticString = #filePath, - line: UInt = #line - ) { - guard case .rejected(let close) = result else { - XCTFail("Expected a stale admission ticket to be rejected", file: file, line: line) - return - } - close() - } - - private func execute(_ actions: [MobileBridgeConnectionRegistry.CloseAction]) { - actions.forEach { $0() } - } - - @MainActor - func testBoundEndpointStartsAcceptingWithoutWaitingForRelayReadiness() async throws { - let acceptStarted = expectation(description: "bound endpoint started accepting connections") - let endpoint = try CancellationBlindOnlineEndpoint { - acceptStarted.fulfill() - } - let listener = MobileBridgeListener(endpointBinder: { endpoint }) - let previousTabManager = TerminalController.shared.tabManager - defer { - listener.stop() - endpoint.releaseOnlineForTest() - TerminalController.shared.tabManager = previousTabManager - } - - listener.start(tabManager: TabManager()) - - await fulfillment(of: [acceptStarted], timeout: 1) - } - - func testRevocationRejectsAnAdmissionThatStartedBeforeTrustWasRemoved() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - let revokedActions = registry.revoke(endpointId: "endpoint-E") - XCTAssertTrue(revokedActions.isEmpty) - - let result = registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - ) - executeRejectedClose(result) - - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) - } - - func testRevocationClaimsARegisteredConnectionExactlyOnce() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )) - - let claimedActions = registry.revoke(endpointId: "endpoint-E") - XCTAssertEqual(claimedActions.count, 1) - execute(claimedActions) - XCTAssertEqual(connection.closeCount, 1) - - registry.unregister(connectionID: connection.connectionID, endpointId: "endpoint-E") - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) - XCTAssertEqual(connection.closeCount, 1) - } - - func testRetrustCreatesANewGenerationWithoutRevalidatingOldTickets() throws { - let registry = MobileBridgeConnectionRegistry() - let staleConnection = FakeConnection() - let currentConnection = FakeConnection() - - let lifecycle = registry.start() - let staleTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) - let currentTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - executeRejectedClose(registry.registerIfCurrent( - connectionID: staleConnection.connectionID, - ticket: staleTicket, - close: staleConnection.closeAction - )) - assertRegistered(registry.registerIfCurrent( - connectionID: currentConnection.connectionID, - ticket: currentTicket, - close: currentConnection.closeAction - )) - - XCTAssertEqual(staleConnection.closeCount, 1) - XCTAssertEqual(currentConnection.closeCount, 0) - let currentActions = registry.revoke(endpointId: "endpoint-E") - XCTAssertEqual(currentActions.count, 1) - execute(currentActions) - XCTAssertEqual(currentConnection.closeCount, 1) - } - - func testStopInvalidatesPendingAdmissionsUntilANewListenerGenerationStarts() throws { - let registry = MobileBridgeConnectionRegistry() - let staleConnection = FakeConnection() - let restartedConnection = FakeConnection() - - let staleLifecycle = registry.start() - let staleTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: staleLifecycle - )) - XCTAssertTrue(registry.stop().isEmpty) - - executeRejectedClose(registry.registerIfCurrent( - connectionID: staleConnection.connectionID, - ticket: staleTicket, - close: staleConnection.closeAction - )) - XCTAssertEqual(staleConnection.closeCount, 1) - XCTAssertNil(registry.beginAdmission(endpointId: "endpoint-E", lifecycle: staleLifecycle)) - - let restartedLifecycle = registry.start() - let restartedTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: restartedLifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: restartedConnection.connectionID, - ticket: restartedTicket, - close: restartedConnection.closeAction - )) - XCTAssertEqual(restartedConnection.closeCount, 0) - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 1) - execute(stoppedActions) - XCTAssertEqual(restartedConnection.closeCount, 1) - } - - func testHandlerFromStoppedLifecycleCannotBeginAdmissionAfterRestart() throws { - let registry = MobileBridgeConnectionRegistry() - let currentConnection = FakeConnection() - - let stoppedLifecycle = registry.start() - XCTAssertTrue(registry.stop().isEmpty) - let currentLifecycle = registry.start() - - XCTAssertNil(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: stoppedLifecycle - )) - let currentTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: currentLifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: currentConnection.connectionID, - ticket: currentTicket, - close: currentConnection.closeAction - )) - XCTAssertEqual(currentConnection.closeCount, 0) - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 1) - execute(stoppedActions) - XCTAssertEqual(currentConnection.closeCount, 1) - } - - func testDisconnectAndRevocationHaveExactlyOneClaimantInEitherOrder() throws { - let registry = MobileBridgeConnectionRegistry() - let disconnectedFirst = FakeConnection() - let revokedFirst = FakeConnection() - - let lifecycle = registry.start() - - let disconnectedTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "disconnect-first", - lifecycle: lifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: disconnectedFirst.connectionID, - ticket: disconnectedTicket, - close: disconnectedFirst.closeAction - )) - registry.unregister( - connectionID: disconnectedFirst.connectionID, - endpointId: "disconnect-first" - ) - XCTAssertTrue(registry.revoke(endpointId: "disconnect-first").isEmpty) - XCTAssertEqual(disconnectedFirst.closeCount, 0) - - let revokedTicket = try XCTUnwrap(registry.beginAdmission( - endpointId: "revoke-first", - lifecycle: lifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: revokedFirst.connectionID, - ticket: revokedTicket, - close: revokedFirst.closeAction - )) - let claimedActions = registry.revoke(endpointId: "revoke-first") - XCTAssertEqual(claimedActions.count, 1) - registry.unregister(connectionID: revokedFirst.connectionID, endpointId: "revoke-first") - execute(claimedActions) - XCTAssertEqual(revokedFirst.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "revoke-first").isEmpty) - } - - func testInvalidatedPairingWindowRejectsItsCapturedToken() { - let token = Data("single-use-secret".utf8) - let window = MobileBridgePairingWindow(token: token, duration: .seconds(60)) - - XCTAssertTrue(window.isOpen) - window.invalidate() - - XCTAssertFalse(window.isOpen) - XCTAssertFalse(window.attemptConsume(token)) - } - - func testPairingWindowConsumesMatchingTokenOnlyOnceAndSurvivesMismatch() { - let token = Data("single-use-secret".utf8) - let window = MobileBridgePairingWindow(token: token, duration: .seconds(60)) - - XCTAssertFalse(window.attemptConsume(Data("wrong-secret".utf8))) - XCTAssertTrue(window.isOpen) - XCTAssertTrue(window.attemptConsume(token)) - XCTAssertFalse(window.isOpen) - XCTAssertFalse(window.attemptConsume(token)) - } - - func testStaleRegistrationRejectsBeforeInvokingTrustCommit() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let commitCount = LockedValue(0) - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) - let result = registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction, - beforeRegister: { - commitCount.withLock { $0 += 1 } - return true - } - ) - executeRejectedClose(result) - - XCTAssertEqual(commitCount.withLock { $0 }, 0) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) - } - - func testFailedTrustCommitRejectsWithoutRegisteringConnection() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let commitCount = LockedValue(0) - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - let result = registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction, - beforeRegister: { - commitCount.withLock { $0 += 1 } - return false - } - ) - executeRejectedClose(result) - - XCTAssertEqual(commitCount.withLock { $0 }, 1) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) - } - - func testSuccessfulTrustCommitRegistersAndTransfersCloseOwnershipExactlyOnce() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let commitCount = LockedValue(0) - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction, - beforeRegister: { - commitCount.withLock { $0 += 1 } - return true - } - )) - - XCTAssertEqual(commitCount.withLock { $0 }, 1) - XCTAssertEqual(connection.closeCount, 0) - let claimedActions = registry.revoke(endpointId: "endpoint-E", beforeClaim: {}) - XCTAssertEqual(claimedActions.count, 1) - execute(claimedActions) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) - XCTAssertEqual(connection.closeCount, 1) - } - - func testCommitBeforeRevokeRemovesTrustBeforeClaimingConnection() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let trustedEndpoints = LockedValue(Set<String>()) - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction, - beforeRegister: { - trustedEndpoints.withLock { _ = $0.insert("endpoint-E") } - return true - } - )) - let claimedActions = registry.revoke( - endpointId: "endpoint-E", - beforeClaim: { - trustedEndpoints.withLock { _ = $0.remove("endpoint-E") } - } - ) - - XCTAssertFalse(trustedEndpoints.withLock { $0.contains("endpoint-E") }) - XCTAssertEqual(claimedActions.count, 1) - execute(claimedActions) - XCTAssertEqual(connection.closeCount, 1) - } - - func testRevokeBeforeCommitRejectsWithoutWritingTrust() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let trustedEndpoints = LockedValue(Set<String>()) - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - XCTAssertTrue(registry.revoke( - endpointId: "endpoint-E", - beforeClaim: { - trustedEndpoints.withLock { _ = $0.remove("endpoint-E") } - } - ).isEmpty) - let result = registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction, - beforeRegister: { - trustedEndpoints.withLock { _ = $0.insert("endpoint-E") } - return true - } - ) - executeRejectedClose(result) - - XCTAssertFalse(trustedEndpoints.withLock { $0.contains("endpoint-E") }) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) - } - - func testStopBeforeCommitRejectsWithoutWritingTrust() throws { - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let trustedEndpoints = LockedValue(Set<String>()) - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - XCTAssertTrue(registry.stop().isEmpty) - let result = registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction, - beforeRegister: { - trustedEndpoints.withLock { _ = $0.insert("endpoint-E") } - return true - } - ) - executeRejectedClose(result) - - XCTAssertFalse(trustedEndpoints.withLock { $0.contains("endpoint-E") }) - XCTAssertEqual(connection.closeCount, 1) - } - - func testPersistenceFailureLeavesNoTrustAndNoLiveConnection() async throws { - let persistenceCount = LockedValue(0) - let store = MobileBridgeTrustedDeviceStore( - fileURL: FileManager.default.temporaryDirectory - .appendingPathComponent("mobile-bridge-persistence-failure-\(UUID().uuidString).json"), - persistence: { _, _ in - persistenceCount.withLock { $0 += 1 } - throw PersistenceFailure.expected - } - ) - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: "endpoint-E", - lifecycle: lifecycle - )) - - let result = await store.registerPairedIfCurrent( - endpointId: "endpoint-E", - label: "Test Phone", - registry: registry, - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - ) - executeRejectedClose(result) - let isTrusted = await store.isTrusted("endpoint-E") - - XCTAssertEqual(persistenceCount.withLock { $0 }, 1) - XCTAssertFalse(isTrusted) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E", beforeClaim: {}).isEmpty) - } - - func testRevokePersistenceFailurePreservesTrustAndLiveConnection() async throws { - let endpointId = "endpoint-revoke-failure" - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("mobile-bridge-revoke-failure-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let device = MobileBridgeTrustedDevice( - endpointId: endpointId, - label: "Failure Test Phone", - pairedAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - try JSONEncoder().encode([device]).write(to: fileURL, options: .atomic) - - let store = MobileBridgeTrustedDeviceStore( - fileURL: fileURL, - persistence: { _, _ in throw PersistenceFailure.expected } - ) - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: endpointId, - lifecycle: lifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )) - - let result = await store.revokeAndClaimConnections( - endpointId: endpointId, - registry: registry - ) - let remainsTrusted = await store.isTrusted(endpointId) - - XCTAssertNotNil(result.persistenceFailure) - XCTAssertTrue(result.closeActions.isEmpty) - XCTAssertTrue(remainsTrusted) - XCTAssertEqual(connection.closeCount, 0) - - let retainedActions = registry.revoke(endpointId: endpointId) - XCTAssertEqual(retainedActions.count, 1) - execute(retainedActions) - XCTAssertEqual(connection.closeCount, 1) - } - - func testSuccessfulRevokePersistsRemovalAndClosesLiveConnection() async throws { - let endpointId = "endpoint-revoke-success" - let fileURL = FileManager.default.temporaryDirectory - .appendingPathComponent("mobile-bridge-revoke-success-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: fileURL) } - - let device = MobileBridgeTrustedDevice( - endpointId: endpointId, - label: "Success Test Phone", - pairedAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - try JSONEncoder().encode([device]).write(to: fileURL, options: .atomic) - - let persistedData = LockedValue<Data?>(nil) - let store = MobileBridgeTrustedDeviceStore( - fileURL: fileURL, - persistence: { data, _ in - persistedData.withLock { $0 = data } - } - ) - let registry = MobileBridgeConnectionRegistry() - let connection = FakeConnection() - let lifecycle = registry.start() - let ticket = try XCTUnwrap(registry.beginAdmission( - endpointId: endpointId, - lifecycle: lifecycle - )) - assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )) - - let result = await store.revokeAndClaimConnections( - endpointId: endpointId, - registry: registry - ) - let remainsTrusted = await store.isTrusted(endpointId) - - XCTAssertNil(result.persistenceFailure) - XCTAssertFalse(remainsTrusted) - XCTAssertEqual(result.closeActions.count, 1) - execute(result.closeActions) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: endpointId).isEmpty) - - let writtenData = try XCTUnwrap(persistedData.withLock { $0 }) - let persistedDevices = try JSONDecoder().decode( - [MobileBridgeTrustedDevice].self, - from: writtenData - ) - XCTAssertFalse(persistedDevices.contains { $0.endpointId == endpointId }) - } - - func testAnonymousAdmissionsCannotExceedTheListenerResourceBudget() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - var leases: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] - - for _ in 0 ..< 10 { - leases.append(try XCTUnwrap(registry.reservePending(lifecycle: lifecycle))) - } - XCTAssertNil( - registry.reservePending(lifecycle: lifecycle), - "An eleventh unauthenticated peer must not allocate another admission task" - ) - - registry.abandonPending(leases.removeLast()) - let replacement = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - XCTAssertNil( - registry.reservePending(lifecycle: lifecycle), - "Replacing an abandoned lease must consume exactly one released slot" - ) - - leases.forEach { registry.abandonPending($0) } - registry.abandonPending(replacement) - } - - func testAnonymousAdmissionDeadlineReleasesStalledPreIdentificationCapacityExactlyOnce() async throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - let timedOutLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - var heldLeases: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] - for _ in 0 ..< 9 { - heldLeases.append(try XCTUnwrap(registry.reservePending(lifecycle: lifecycle))) - } - XCTAssertNil(registry.reservePending(lifecycle: lifecycle)) - - let timeoutClose = FakeConnection() - let timeoutFired = expectation(description: "stalled anonymous admission timed out") - let deadline = MobileBridgeListener.startPendingAdmissionDeadline( - registry: registry, - lease: timedOutLease, - timeout: .milliseconds(10) - ) { - timeoutClose.closeAction() - timeoutFired.fulfill() - } - - await fulfillment(of: [timeoutFired], timeout: 1) - XCTAssertEqual(timeoutClose.closeCount, 1) - XCTAssertFalse( - registry.abandonPending(timedOutLease), - "Timeout must own and release the stalled anonymous lease exactly once" - ) - - let replacement = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - XCTAssertNil( - registry.reservePending(lifecycle: lifecycle), - "A single timeout must release exactly one admission slot" - ) - deadline.cancel() - XCTAssertEqual(timeoutClose.closeCount, 1) - - heldLeases.forEach { registry.abandonPending($0) } - registry.abandonPending(replacement) - } - - func testIdentifiedAdmissionsLimitEachEndpointWithoutBlockingOtherDevices() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - let firstConnection = FakeConnection() - let duplicateConnection = FakeConnection() - let otherConnection = FakeConnection() - - let firstLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let firstTicket = try XCTUnwrap(registry.identifyPending( - firstLease, - endpointId: "endpoint-E", - close: firstConnection.closeAction - )) - - let duplicateLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - XCTAssertNil(registry.identifyPending( - duplicateLease, - endpointId: "endpoint-E", - close: duplicateConnection.closeAction - )) - duplicateConnection.closeAction() - - let otherLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let otherTicket = try XCTUnwrap(registry.identifyPending( - otherLease, - endpointId: "endpoint-F", - close: otherConnection.closeAction - )) - - XCTAssertEqual(firstConnection.closeCount, 0) - XCTAssertEqual(duplicateConnection.closeCount, 1) - XCTAssertEqual(otherConnection.closeCount, 0) - - let firstClose = try XCTUnwrap(registry.abandonAdmission(firstTicket)) - let otherClose = try XCTUnwrap(registry.abandonAdmission(otherTicket)) - firstClose() - otherClose() - XCTAssertEqual(firstConnection.closeCount, 1) - XCTAssertEqual(duplicateConnection.closeCount, 1) - XCTAssertEqual(otherConnection.closeCount, 1) - } - - func testExpiredAdmissionReleasesCapacityWithoutRestoringCloseOwnership() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - let expiredConnection = FakeConnection() - - let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let ticket = try XCTUnwrap(registry.identifyPending( - lease, - endpointId: "endpoint-E", - close: expiredConnection.closeAction - )) - let expiredClose = try XCTUnwrap(registry.expireAdmission(ticket)) - expiredClose() - XCTAssertEqual(expiredConnection.closeCount, 1) - - let registration = registry.registerIfCurrent( - connectionID: expiredConnection.connectionID, - ticket: ticket, - close: expiredConnection.closeAction - ) - executeRejectedClose(registration) - XCTAssertNil(registry.abandonAdmission(ticket)) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) - XCTAssertEqual( - expiredConnection.closeCount, - 1, - "Every path retaining a stale ticket must share the admission's exactly-once close ownership" - ) - - var replacements: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] - for _ in 0 ..< 10 { - replacements.append(try XCTUnwrap(registry.reservePending(lifecycle: lifecycle))) - } - XCTAssertNil(registry.reservePending(lifecycle: lifecycle)) - replacements.forEach { registry.abandonPending($0) } - } - - func testRegistrationTransfersIdentifiedAdmissionOwnershipToTheLiveConnection() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - let connection = FakeConnection() - - let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let ticket = try XCTUnwrap(registry.identifyPending( - lease, - endpointId: "endpoint-E", - close: connection.closeAction - )) - assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )) - - XCTAssertNil(registry.expireAdmission(ticket)) - XCTAssertNil(registry.abandonAdmission(ticket)) - let revokedActions = registry.revoke(endpointId: "endpoint-E") - XCTAssertEqual(revokedActions.count, 1) - execute(revokedActions) - XCTAssertEqual(connection.closeCount, 1) - XCTAssertTrue(registry.revoke(endpointId: "endpoint-E").isEmpty) - XCTAssertEqual(connection.closeCount, 1) - } - - func testStopClaimsOwnedConnectionsAndInvalidatesEveryPendingLease() throws { - let registry = MobileBridgeConnectionRegistry() - let stoppedLifecycle = registry.start() - let anonymousConnection = FakeConnection() - let pendingConnection = FakeConnection() - let liveConnection = FakeConnection() - - let anonymousLease = try XCTUnwrap(registry.reservePending(lifecycle: stoppedLifecycle)) - - let pendingLease = try XCTUnwrap(registry.reservePending(lifecycle: stoppedLifecycle)) - _ = try XCTUnwrap(registry.identifyPending( - pendingLease, - endpointId: "pending-endpoint", - close: pendingConnection.closeAction - )) - - let liveLease = try XCTUnwrap(registry.reservePending(lifecycle: stoppedLifecycle)) - let liveTicket = try XCTUnwrap(registry.identifyPending( - liveLease, - endpointId: "live-endpoint", - close: liveConnection.closeAction - )) - assertRegistered(registry.registerIfCurrent( - connectionID: liveConnection.connectionID, - ticket: liveTicket, - close: liveConnection.closeAction - )) - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 2) - execute(stoppedActions) - XCTAssertEqual(pendingConnection.closeCount, 1) - XCTAssertEqual(liveConnection.closeCount, 1) - - XCTAssertNil(registry.identifyPending( - anonymousLease, - endpointId: "anonymous-endpoint", - close: anonymousConnection.closeAction - )) - anonymousConnection.closeAction() - XCTAssertEqual(anonymousConnection.closeCount, 1) - XCTAssertNil(registry.reservePending(lifecycle: stoppedLifecycle)) - - let restartedLifecycle = registry.start() - let restartedLease = try XCTUnwrap(registry.reservePending(lifecycle: restartedLifecycle)) - registry.abandonPending(restartedLease) - XCTAssertTrue(registry.stop().isEmpty) - XCTAssertEqual(pendingConnection.closeCount, 1) - XCTAssertEqual(liveConnection.closeCount, 1) - } - - func testStaleListenerLifecycleCannotAcquireOrPromoteAdmissionCapacity() throws { - let registry = MobileBridgeConnectionRegistry() - let anonymousConnection = FakeConnection() - let identifiedConnection = FakeConnection() - let currentConnection = FakeConnection() - let staleLifecycle = registry.start() - let staleLease = try XCTUnwrap(registry.reservePending(lifecycle: staleLifecycle)) - let identifiedLease = try XCTUnwrap(registry.reservePending(lifecycle: staleLifecycle)) - let staleTicket = try XCTUnwrap(registry.identifyPending( - identifiedLease, - endpointId: "identified-endpoint", - close: identifiedConnection.closeAction - )) - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 1) - execute(stoppedActions) - let currentLifecycle = registry.start() - - XCTAssertNil(registry.reservePending(lifecycle: staleLifecycle)) - XCTAssertNil(registry.identifyPending( - staleLease, - endpointId: "anonymous-endpoint", - close: anonymousConnection.closeAction - )) - anonymousConnection.closeAction() - executeRejectedClose(registry.registerIfCurrent( - connectionID: identifiedConnection.connectionID, - ticket: staleTicket, - close: identifiedConnection.closeAction - )) - XCTAssertEqual(anonymousConnection.closeCount, 1) - XCTAssertEqual(identifiedConnection.closeCount, 1) - - let currentLease = try XCTUnwrap(registry.reservePending(lifecycle: currentLifecycle)) - let currentTicket = try XCTUnwrap(registry.identifyPending( - currentLease, - endpointId: "endpoint-E", - close: currentConnection.closeAction - )) - let currentClose = try XCTUnwrap(registry.abandonAdmission(currentTicket)) - currentClose() - XCTAssertEqual(currentConnection.closeCount, 1) - XCTAssertTrue(registry.stop().isEmpty) - XCTAssertEqual(identifiedConnection.closeCount, 1) - } - - func testDistinctEndpointsCannotExceedTheLiveConnectionBudget() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - var liveConnections: [FakeConnection] = [] - - for index in 0 ..< 10 { - let connection = FakeConnection() - let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let ticket = try XCTUnwrap(registry.identifyPending( - lease, - endpointId: "endpoint-\(index)", - close: connection.closeAction - )) - XCTAssertTrue(assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )).isEmpty) - liveConnections.append(connection) - } - - let overflow = FakeConnection() - let overflowCommitCount = LockedValue(0) - let overflowLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let overflowTicket = try XCTUnwrap(registry.identifyPending( - overflowLease, - endpointId: "endpoint-overflow", - close: overflow.closeAction - )) - executeRejectedClose(registry.registerIfCurrent( - connectionID: overflow.connectionID, - ticket: overflowTicket, - close: overflow.closeAction, - beforeRegister: { - overflowCommitCount.withLock { $0 += 1 } - return true - } - )) - - XCTAssertEqual(overflowCommitCount.withLock { $0 }, 0) - XCTAssertEqual(overflow.closeCount, 1) - XCTAssertTrue(liveConnections.allSatisfy { $0.closeCount == 0 }) - - var reusablePendingCapacity: [MobileBridgeConnectionRegistry.PendingAdmissionLease] = [] - for _ in 0 ..< 10 { - reusablePendingCapacity.append(try XCTUnwrap( - registry.reservePending(lifecycle: lifecycle) - )) - } - XCTAssertNil(registry.reservePending(lifecycle: lifecycle)) - reusablePendingCapacity.forEach { registry.abandonPending($0) } - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 10) - execute(stoppedActions) - XCTAssertTrue(liveConnections.allSatisfy { $0.closeCount == 1 }) - XCTAssertEqual(overflow.closeCount, 1) - } - - func testAuthenticatedReconnectAtCapacitySupersedesOnlyItsPriorConnection() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - var originalConnections: [FakeConnection] = [] - - for index in 0 ..< 10 { - let connection = FakeConnection() - let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let ticket = try XCTUnwrap(registry.identifyPending( - lease, - endpointId: "endpoint-\(index)", - close: connection.closeAction - )) - XCTAssertTrue(assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )).isEmpty) - originalConnections.append(connection) - } - - let replacement = FakeConnection() - let commitCount = LockedValue(0) - let replacementLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let replacementTicket = try XCTUnwrap(registry.identifyPending( - replacementLease, - endpointId: "endpoint-0", - close: replacement.closeAction - )) - let superseded = assertRegistered(registry.registerIfCurrent( - connectionID: replacement.connectionID, - ticket: replacementTicket, - close: replacement.closeAction, - beforeRegister: { - commitCount.withLock { $0 += 1 } - return true - } - )) - - XCTAssertEqual(commitCount.withLock { $0 }, 1) - XCTAssertEqual(superseded.count, 1) - XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 0 }) - XCTAssertEqual(replacement.closeCount, 0) - - execute(superseded) - XCTAssertEqual(originalConnections[0].closeCount, 1) - XCTAssertTrue(originalConnections.dropFirst().allSatisfy { $0.closeCount == 0 }) - XCTAssertEqual(replacement.closeCount, 0) - - let revokedActions = registry.revoke(endpointId: "endpoint-0") - XCTAssertEqual(revokedActions.count, 1) - execute(revokedActions) - XCTAssertEqual(originalConnections[0].closeCount, 1) - XCTAssertEqual(replacement.closeCount, 1) - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 9) - execute(stoppedActions) - XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 1 }) - XCTAssertEqual(replacement.closeCount, 1) - } - - func testFailedReconnectAtCapacityCannotDisruptTheActiveConnection() throws { - let registry = MobileBridgeConnectionRegistry() - let lifecycle = registry.start() - var originalConnections: [FakeConnection] = [] - - for index in 0 ..< 10 { - let connection = FakeConnection() - let lease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let ticket = try XCTUnwrap(registry.identifyPending( - lease, - endpointId: "endpoint-\(index)", - close: connection.closeAction - )) - XCTAssertTrue(assertRegistered(registry.registerIfCurrent( - connectionID: connection.connectionID, - ticket: ticket, - close: connection.closeAction - )).isEmpty) - originalConnections.append(connection) - } - - let failedReplacement = FakeConnection() - let failedCommitCount = LockedValue(0) - let replacementLease = try XCTUnwrap(registry.reservePending(lifecycle: lifecycle)) - let replacementTicket = try XCTUnwrap(registry.identifyPending( - replacementLease, - endpointId: "endpoint-0", - close: failedReplacement.closeAction - )) - executeRejectedClose(registry.registerIfCurrent( - connectionID: failedReplacement.connectionID, - ticket: replacementTicket, - close: failedReplacement.closeAction, - beforeRegister: { - failedCommitCount.withLock { $0 += 1 } - return false - } - )) - - XCTAssertEqual(failedCommitCount.withLock { $0 }, 1) - XCTAssertEqual(failedReplacement.closeCount, 1) - XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 0 }) - - let revokedActions = registry.revoke(endpointId: "endpoint-0") - XCTAssertEqual(revokedActions.count, 1) - execute(revokedActions) - XCTAssertEqual(originalConnections[0].closeCount, 1) - XCTAssertEqual(failedReplacement.closeCount, 1) - - let stoppedActions = registry.stop() - XCTAssertEqual(stoppedActions.count, 9) - execute(stoppedActions) - XCTAssertTrue(originalConnections.allSatisfy { $0.closeCount == 1 }) - XCTAssertEqual(failedReplacement.closeCount, 1) - } - - func testLineReaderReassemblesAFrameFromSingleByteReads() async throws { - let stream = FragmentingRecvStream( - data: Data("fragmented payload\nnext frame\n".utf8), - maximumBytesPerRead: 1 - ) - let reader = MobileBridgeStreamLineReader(stream: stream) - - let first = try await reader.nextLine() - let second = try await reader.nextLine() - let end = try await reader.nextLine() - - XCTAssertEqual(first, Data("fragmented payload".utf8)) - XCTAssertEqual(second, Data("next frame".utf8)) - XCTAssertNil(end) - } - - func testLineReaderAcceptsExactlyEightMiBWithoutTruncatingTheFrame() async throws { - let maximumLineByteCount = 8 * 1024 * 1024 - var framed = Data(repeating: 0x61, count: maximumLineByteCount) - framed.append(0x0A) - let reader = MobileBridgeStreamLineReader(stream: FragmentingRecvStream( - data: framed, - maximumBytesPerRead: 65_536 - )) - - let receivedLine = try await reader.nextLine() - let line = try XCTUnwrap(receivedLine) - - XCTAssertEqual(line.count, maximumLineByteCount) - XCTAssertEqual(line.first, 0x61) - XCTAssertEqual(line.last, 0x61) - let end = try await reader.nextLine() - XCTAssertNil(end) - } - - func testLineReaderRejectsAFrameOneByteBeyondEightMiB() async throws { - let maximumLineByteCount = 8 * 1024 * 1024 - var framed = Data(repeating: 0x61, count: maximumLineByteCount + 1) - framed.append(0x0A) - let reader = MobileBridgeStreamLineReader(stream: FragmentingRecvStream( - data: framed, - maximumBytesPerRead: 65_536 - )) - - do { - _ = try await reader.nextLine() - XCTFail("A frame above the bridge's memory bound must be rejected") - } catch MobileBridgeStreamLineReaderError.frameTooLarge { - // Expected: an unauthenticated peer cannot grow the framing buffer beyond its cap. - } catch { - XCTFail("Expected frameTooLarge, got \(error)") - } - } - - func testLineReaderReturnsAnUnterminatedFinalFrameThenStableEOF() async throws { - let reader = MobileBridgeStreamLineReader(stream: FragmentingRecvStream( - data: Data("final frame without newline".utf8), - maximumBytesPerRead: 3 - )) - - let finalFrame = try await reader.nextLine() - let firstEOF = try await reader.nextLine() - let secondEOF = try await reader.nextLine() - - XCTAssertEqual(finalFrame, Data("final frame without newline".utf8)) - XCTAssertNil(firstEOF) - XCTAssertNil(secondEOF) - } - - func testRelayPumpClosesBothBlockingDirectionsWhenLocalControlReachesEOF() async { - let phoneParked = expectation(description: "phone read parked") - let localParked = expectation(description: "local read parked") - let pumpCompleted = expectation(description: "relay pump completed") - let phoneSource = ParkingRelayLineSource { phoneParked.fulfill() } - let localSource = ParkingRelayLineSource { localParked.fulfill() } - let pipe = ParkingRelayLocalPipe(source: localSource) - let remoteCloseCount = LockedValue(0) - - let task = Task { - await MobileBridgeSession.pump( - reader: ParkingRelayPhoneReader(source: phoneSource), - writer: RecordingRelayWriter(), - pipe: pipe, - closeRemote: { - remoteCloseCount.withLock { $0 += 1 } - phoneSource.finish() - } - ) - pumpCompleted.fulfill() - } - - await fulfillment(of: [phoneParked, localParked], timeout: 1) - localSource.finish() - await fulfillment(of: [pumpCompleted], timeout: 1) - - // Keep a broken implementation from retaining parked continuations - // after XCTest records the bounded timeout failure. - phoneSource.finish() - pipe.forceUnblockForTest() - task.cancel() - _ = await task.result - - XCTAssertEqual( - remoteCloseCount.withLock { $0 }, - 1, - "Local EOF must close the cancellation-blind phone read exactly once" - ) - XCTAssertEqual( - pipe.shutdownCount, - 1, - "Local EOF cleanup must not race the pump into shutting down its local end twice" - ) - } - - func testRelayPumpClosesBothBlockingDirectionsWhenPhoneReachesEOF() async { - let phoneParked = expectation(description: "phone read parked") - let localParked = expectation(description: "local read parked") - let pumpCompleted = expectation(description: "relay pump completed") - let phoneSource = ParkingRelayLineSource { phoneParked.fulfill() } - let localSource = ParkingRelayLineSource { localParked.fulfill() } - let pipe = ParkingRelayLocalPipe(source: localSource) - let remoteCloseCount = LockedValue(0) - - let task = Task { - await MobileBridgeSession.pump( - reader: ParkingRelayPhoneReader(source: phoneSource), - writer: RecordingRelayWriter(), - pipe: pipe, - closeRemote: { - remoteCloseCount.withLock { $0 += 1 } - phoneSource.finish() - } - ) - pumpCompleted.fulfill() - } - - await fulfillment(of: [phoneParked, localParked], timeout: 1) - phoneSource.finish() - await fulfillment(of: [pumpCompleted], timeout: 1) - - phoneSource.finish() - pipe.forceUnblockForTest() - task.cancel() - _ = await task.result - - XCTAssertEqual( - remoteCloseCount.withLock { $0 }, - 1, - "Phone EOF cleanup must not race the pump into closing the remote side twice" - ) - XCTAssertEqual( - pipe.shutdownCount, - 1, - "Phone EOF must unblock the cancellation-blind local read exactly once" - ) - } - - func testCancellingRelayPumpClosesBothBlockingDirectionsAndCompletes() async { - let phoneParked = expectation(description: "phone read parked") - let localParked = expectation(description: "local read parked") - let pumpCompleted = expectation(description: "cancelled relay pump completed") - let phoneSource = ParkingRelayLineSource { phoneParked.fulfill() } - let localSource = ParkingRelayLineSource { localParked.fulfill() } - let pipe = ParkingRelayLocalPipe(source: localSource) - let remoteCloseCount = LockedValue(0) - - let task = Task { - await MobileBridgeSession.pump( - reader: ParkingRelayPhoneReader(source: phoneSource), - writer: RecordingRelayWriter(), - pipe: pipe, - closeRemote: { - remoteCloseCount.withLock { $0 += 1 } - phoneSource.finish() - } - ) - pumpCompleted.fulfill() - } - - await fulfillment(of: [phoneParked, localParked], timeout: 1) - task.cancel() - await fulfillment(of: [pumpCompleted], timeout: 1) - - phoneSource.finish() - pipe.forceUnblockForTest() - task.cancel() - _ = await task.result - - XCTAssertEqual( - remoteCloseCount.withLock { $0 }, - 1, - "Cancellation cleanup and normal pump cleanup must share remote close ownership" - ) - XCTAssertEqual( - pipe.shutdownCount, - 1, - "Cancellation cleanup and normal pump cleanup must share local shutdown ownership" - ) - } -} diff --git a/programaTests/NotificationAndMenuBarTests.swift b/programaTests/NotificationAndMenuBarTests.swift index fdda02b8..064c0558 100644 --- a/programaTests/NotificationAndMenuBarTests.swift +++ b/programaTests/NotificationAndMenuBarTests.swift @@ -109,7 +109,7 @@ final class NotificationDockBadgeTests: XCTestCase { XCTAssertNotNil(NotificationSoundSettings.sound(defaults: defaults)) } - func testNotificationSoundDisablesSystemSoundForNoneAndCustomFile() { + func testNotificationSoundDisablesSystemSoundForNone() { let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { XCTFail("Failed to create isolated UserDefaults suite") @@ -122,247 +122,6 @@ final class NotificationDockBadgeTests: XCTestCase { defaults.set("none", forKey: NotificationSoundSettings.key) XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.usesSystemSound(defaults: defaults)) - XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) - } - - func testNotificationCustomFileURLExpandsTildePath() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let rawPath = "~/Library/Sounds/my-custom.wav" - defaults.set(rawPath, forKey: NotificationSoundSettings.customFilePathKey) - let expectedPath = (rawPath as NSString).expandingTildeInPath - XCTAssertEqual(NotificationSoundSettings.customFileURL(defaults: defaults)?.path, expectedPath) - } - - func testNotificationCustomFileSelectionMustBeExplicit() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - defaults.set("~/Library/Sounds/my-custom.wav", forKey: NotificationSoundSettings.customFilePathKey) - - defaults.set("none", forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) - - defaults.set("Ping", forKey: NotificationSoundSettings.key) - XCTAssertFalse(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - XCTAssertTrue(NotificationSoundSettings.isCustomFileSelected(defaults: defaults)) - } - - func testNotificationCustomStagingPreservesSourceFileWithProgramaPrefix() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let fileManager = FileManager.default - let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - do { - try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) - } catch { - XCTFail("Failed to create sounds directory: \(error)") - return - } - - let sourceURL = soundsDirectory.appendingPathComponent( - "cmux-custom-notification-sound.source-\(UUID().uuidString).wav", - isDirectory: false - ) - defer { - try? fileManager.removeItem(at: sourceURL) - } - - do { - try Data("test".utf8).write(to: sourceURL, options: .atomic) - } catch { - XCTFail("Failed to write source custom sound file: \(error)") - return - } - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) - - _ = NotificationSoundSettings.sound(defaults: defaults) - - guard let stagedName = NotificationSoundSettings.stagedCustomSoundName(defaults: defaults) else { - XCTFail("Expected staged custom sound name") - return - } - let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) - defer { - try? fileManager.removeItem(at: stagedURL) - } - - XCTAssertTrue(fileManager.fileExists(atPath: sourceURL.path)) - XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) - XCTAssertTrue(stagedName.hasPrefix("cmux-custom-notification-sound-")) - XCTAssertTrue(stagedName.hasSuffix(".wav")) - } - - func testNotificationCustomUnsupportedExtensionsStageAsCaf() { - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "mp3"), - "caf" - ) - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "M4A"), - "caf" - ) - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "wav"), - "wav" - ) - XCTAssertEqual( - NotificationSoundSettings.stagedCustomSoundFileExtension(forSourceExtension: "AIFF"), - "aiff" - ) - - let sourceA = URL(fileURLWithPath: "/tmp/custom-a.mp3") - let sourceB = URL(fileURLWithPath: "/tmp/custom-b.mp3") - let stagedA = NotificationSoundSettings.stagedCustomSoundFileName( - forSourceURL: sourceA, - destinationExtension: "caf" - ) - let stagedB = NotificationSoundSettings.stagedCustomSoundFileName( - forSourceURL: sourceB, - destinationExtension: "caf" - ) - XCTAssertNotEqual(stagedA, stagedB) - XCTAssertTrue(stagedA.hasPrefix("cmux-custom-notification-sound-")) - XCTAssertTrue(stagedA.hasSuffix(".caf")) - } - - func testNotificationCustomPreparationKeepsActiveSourceMetadataSidecar() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let fileManager = FileManager.default - let soundsDirectory = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - do { - try fileManager.createDirectory(at: soundsDirectory, withIntermediateDirectories: true) - } catch { - XCTFail("Failed to create sounds directory: \(error)") - return - } - - let sourceURL = soundsDirectory.appendingPathComponent( - "cmux-custom-notification-sound.metadata-\(UUID().uuidString).wav", - isDirectory: false - ) - do { - try Data("test".utf8).write(to: sourceURL, options: .atomic) - } catch { - XCTFail("Failed to write source custom sound file: \(error)") - return - } - defer { - try? fileManager.removeItem(at: sourceURL) - } - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - defaults.set(sourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) - - let prepareResult = NotificationSoundSettings.prepareCustomFileForNotifications(path: sourceURL.path) - let stagedName: String - switch prepareResult { - case .success(let name): - stagedName = name - case .failure(let issue): - XCTFail("Expected custom sound preparation success, got \(issue)") - return - } - - let stagedURL = soundsDirectory.appendingPathComponent(stagedName, isDirectory: false) - let metadataURL = stagedURL.appendingPathExtension("source-metadata") - defer { - try? fileManager.removeItem(at: stagedURL) - try? fileManager.removeItem(at: metadataURL) - } - - XCTAssertTrue(fileManager.fileExists(atPath: stagedURL.path)) - XCTAssertTrue(fileManager.fileExists(atPath: metadataURL.path)) - } - - func testNotificationCustomSoundReturnsNilWhenPreparationFails() { - let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { - defaults.removePersistentDomain(forName: suiteName) - } - - let invalidSourceURL = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-invalid-sound-\(UUID().uuidString).mp3", isDirectory: false) - defer { - try? FileManager.default.removeItem(at: invalidSourceURL) - let stagedURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Sounds", isDirectory: true) - .appendingPathComponent("cmux-custom-notification-sound.caf", isDirectory: false) - try? FileManager.default.removeItem(at: stagedURL) - } - - do { - try Data("not-audio".utf8).write(to: invalidSourceURL, options: .atomic) - } catch { - XCTFail("Failed to write invalid custom sound source: \(error)") - return - } - - defaults.set(NotificationSoundSettings.customFileValue, forKey: NotificationSoundSettings.key) - defaults.set(invalidSourceURL.path, forKey: NotificationSoundSettings.customFilePathKey) - - XCTAssertNil(NotificationSoundSettings.sound(defaults: defaults)) - } - - func testNotificationCustomPreparationReportsMissingFile() { - let missingPath = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-missing-\(UUID().uuidString).wav", isDirectory: false) - .path - - let result = NotificationSoundSettings.prepareCustomFileForNotifications(path: missingPath) - switch result { - case .success: - XCTFail("Expected missing file failure") - case .failure(let issue): - guard case .missingFile = issue else { - XCTFail("Expected missingFile issue, got \(issue)") - return - } - } } func testFocusedTerminalNotificationStillRunsLocalSoundFeedbackWhenExternalDeliveryIsSuppressed() throws { diff --git a/programaTests/OmnibarAndToolsTests.swift b/programaTests/OmnibarAndToolsTests.swift index 53157f4a..98c0e911 100644 --- a/programaTests/OmnibarAndToolsTests.swift +++ b/programaTests/OmnibarAndToolsTests.swift @@ -156,105 +156,6 @@ final class FinderServicePathResolverTests: XCTestCase { } -final class VSCodeServeWebURLBuilderTests: XCTestCase { - func testExtractWebUIURLParsesServeWebOutput() { - let output = """ - * - * Visual Studio Code Server - * - Web UI available at http://127.0.0.1:5555?tkn=test-token - """ - - let url = VSCodeServeWebURLBuilder.extractWebUIURL(from: output) - XCTAssertEqual(url?.absoluteString, "http://127.0.0.1:5555?tkn=test-token") - } - - func testOpenFolderURLAppendsFolderQueryWhilePreservingToken() { - let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token")! - - let url = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: baseURL, - directoryPath: "/Users/tester/Projects/cmux" - ) - - let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) - XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "tkn" })?.value, "test-token") - XCTAssertEqual(components?.queryItems?.first(where: { $0.name == "folder" })?.value, "/Users/tester/Projects/cmux") - } - - func testOpenFolderURLReplacesExistingFolderQuery() { - let baseURL = URL(string: "http://127.0.0.1:5555?tkn=test-token&folder=/tmp/old")! - - let url = VSCodeServeWebURLBuilder.openFolderURL( - baseWebUIURL: baseURL, - directoryPath: "/Users/tester/New Folder" - ) - - let components = URLComponents(url: url!, resolvingAgainstBaseURL: false) - XCTAssertEqual( - components?.queryItems?.filter { $0.name == "folder" }.count, - 1 - ) - XCTAssertEqual( - components?.queryItems?.first(where: { $0.name == "folder" })?.value, - "/Users/tester/New Folder" - ) - } -} - - -final class VSCodeCLILaunchConfigurationBuilderTests: XCTestCase { - func testLaunchConfigurationUsesCodeTunnelBinary() { - let appURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) - let expectedExecutablePath = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code-tunnel" - - let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: appURL, - baseEnvironment: [:], - isExecutableAtPath: { $0 == expectedExecutablePath } - ) - - XCTAssertEqual(configuration?.executableURL.path, expectedExecutablePath) - XCTAssertEqual(configuration?.argumentsPrefix, []) - XCTAssertEqual(configuration?.environment["ELECTRON_RUN_AS_NODE"], "1") - } - - func testLaunchConfigurationMapsNodeEnvironmentVariables() { - let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), - baseEnvironment: [ - "PATH": "/usr/bin:/bin", - "NODE_OPTIONS": "--max-old-space-size=4096", - "NODE_REPL_EXTERNAL_MODULE": "module-name" - ], - isExecutableAtPath: { _ in true } - ) - - XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") - XCTAssertEqual(configuration?.environment["VSCODE_NODE_OPTIONS"], "--max-old-space-size=4096") - XCTAssertEqual(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"], "module-name") - XCTAssertNil(configuration?.environment["NODE_OPTIONS"]) - XCTAssertNil(configuration?.environment["NODE_REPL_EXTERNAL_MODULE"]) - } - - func testLaunchConfigurationClearsStaleVSCodeNodeVariablesWhenNodeVariablesAreAbsent() { - let configuration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true), - baseEnvironment: [ - "PATH": "/usr/bin:/bin", - "VSCODE_NODE_OPTIONS": "--stale", - "VSCODE_NODE_REPL_EXTERNAL_MODULE": "stale-module" - ], - isExecutableAtPath: { _ in true } - ) - - XCTAssertEqual(configuration?.environment["PATH"], "/usr/bin:/bin") - XCTAssertNil(configuration?.environment["VSCODE_NODE_OPTIONS"]) - XCTAssertNil(configuration?.environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"]) - } -} - - final class CanonicalSubprocessRunnerTests: XCTestCase { func testSeparatelyCapturesBoundedStandardOutputAndError() { let result = CanonicalSubprocessRunner.run( @@ -316,7 +217,7 @@ final class CanonicalSubprocessRunnerTests: XCTestCase { descendantPIDFile.path, ], currentDirectory: FileManager.default.temporaryDirectory.path, - timeout: 0.05, + timeout: 0.5, stdoutLimit: 64, stderrLimit: 64 ) @@ -344,198 +245,6 @@ final class CanonicalSubprocessRunnerTests: XCTestCase { } } -final class ServeWebOutputCollectorTests: XCTestCase { - func testAcceptsURLWhenOutputEndsAtExactByteBoundary() { - let urlLine = "Web UI available at http://127.0.0.1:7777\n" - let maximumBytes = 96 - let collector = ServeWebOutputCollector(maximumBytes: maximumBytes) - let output = String(repeating: "x", count: maximumBytes - urlLine.utf8.count) + urlLine - - collector.append(Data(output.utf8)) - - XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1)) - XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777") - XCTAssertFalse(collector.didOverflow) - } - - func testOverflowSignalsWaiterAndNeverReturnsPartialURL() { - let collector = ServeWebOutputCollector(maximumBytes: 32) - collector.append(Data(String(repeating: "x", count: 32).utf8)) - collector.append(Data("Web UI available at http://127.0.0.1:7777\n".utf8)) - - XCTAssertFalse(collector.waitForURL(timeoutSeconds: 0.1)) - XCTAssertNil(collector.webUIURL) - XCTAssertTrue(collector.didOverflow) - } - - func testRecognizesURLSplitAcrossChunks() { - let collector = ServeWebOutputCollector(maximumBytes: 128) - collector.append(Data("Web UI available at http://127.0.".utf8)) - collector.append(Data("0.1:8123?tkn=split\n".utf8)) - - XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1)) - XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:8123?tkn=split") - } - - func testWaitForURLReturnsFalseAfterProcessExitSignal() { - let collector = ServeWebOutputCollector() - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { - collector.markProcessExited() - } - - let start = Date() - let resolved = collector.waitForURL(timeoutSeconds: 1) - let elapsed = Date().timeIntervalSince(start) - - XCTAssertFalse(resolved) - XCTAssertLessThan(elapsed, 0.5) - } - - func testWaitForURLReturnsTrueWhenURLIsCollected() { - let collector = ServeWebOutputCollector() - let urlLine = "Web UI available at http://127.0.0.1:7777?tkn=test-token\n" - - DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { - collector.append(Data(urlLine.utf8)) - } - - XCTAssertTrue(collector.waitForURL(timeoutSeconds: 1)) - XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:7777?tkn=test-token") - } - - func testMarkProcessExitedParsesFinalURLWithoutTrailingNewline() { - let collector = ServeWebOutputCollector() - let finalChunk = "Web UI available at http://127.0.0.1:9001?tkn=final-token" - - collector.append(Data(finalChunk.utf8)) - collector.markProcessExited() - - XCTAssertTrue(collector.waitForURL(timeoutSeconds: 0.1)) - XCTAssertEqual(collector.webUIURL?.absoluteString, "http://127.0.0.1:9001?tkn=final-token") - } -} - - -final class VSCodeServeWebControllerTests: XCTestCase { - func testStopDuringInFlightLaunchDoesNotDropNextGenerationCompletion() { - let firstLaunchStarted = expectation(description: "first launch started") - let firstCompletionCalled = expectation(description: "first generation completion called") - let secondCompletionCalled = expectation(description: "second generation completion called") - - let launchGate = DispatchSemaphore(value: 0) - let launchCallLock = NSLock() - var launchCallCount = 0 - - let controller = VSCodeServeWebController.makeForTesting { _, _ in - launchCallLock.lock() - launchCallCount += 1 - let callNumber = launchCallCount - launchCallLock.unlock() - - if callNumber == 1 { - firstLaunchStarted.fulfill() - _ = launchGate.wait(timeout: .now() + 1) - } - return nil - } - - let callbackLock = NSLock() - var firstGenerationCallbacks: [URL?] = [] - var secondGenerationCallbacks: [URL?] = [] - let vscodeAppURL = URL(fileURLWithPath: "/Applications/Visual Studio Code.app", isDirectory: true) - - controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in - callbackLock.lock() - firstGenerationCallbacks.append(url) - callbackLock.unlock() - firstCompletionCalled.fulfill() - } - - wait(for: [firstLaunchStarted], timeout: 1) - controller.stop() - - controller.ensureServeWebURL(vscodeApplicationURL: vscodeAppURL) { url in - callbackLock.lock() - secondGenerationCallbacks.append(url) - callbackLock.unlock() - secondCompletionCalled.fulfill() - } - - launchGate.signal() - wait(for: [firstCompletionCalled, secondCompletionCalled], timeout: 2) - - callbackLock.lock() - let firstSnapshot = firstGenerationCallbacks - let secondSnapshot = secondGenerationCallbacks - callbackLock.unlock() - - launchCallLock.lock() - let launchCalls = launchCallCount - launchCallLock.unlock() - - XCTAssertEqual(firstSnapshot.count, 1) - if firstSnapshot.count == 1 { - XCTAssertNil(firstSnapshot[0]) - } - XCTAssertEqual(secondSnapshot.count, 1) - if secondSnapshot.count == 1 { - XCTAssertNil(secondSnapshot[0]) - } - XCTAssertEqual(launchCalls, 2) - } - - func testStopRemovesOrphanedConnectionTokenFiles() throws { - let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: tokenFileURL) } - try Data("token".utf8).write(to: tokenFileURL) - XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path)) - - let controller = VSCodeServeWebController.makeForTesting { _, _ in - XCTFail("Expected no launch") - return nil - } - controller.trackConnectionTokenFileForTesting(tokenFileURL) - - controller.stop() - - XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path)) - } - - func testStopDoesNotRemovePersistentConnectionTokenFile() throws { - // Persistent token files live under Application Support, not NSTemporaryDirectory. - // stop() must leave them intact so Settings Sync / auth survives restarts (issue #21). - let appSupportDir = try XCTUnwrap( - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first, - "Application Support directory must be resolvable" - ) - let persistentTokenDir = appSupportDir - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("vscode-server", isDirectory: true) - try FileManager.default.createDirectory(at: persistentTokenDir, withIntermediateDirectories: true) - // Use a unique file name to avoid interfering with the real connection-token. - let tokenFileURL = persistentTokenDir - .appendingPathComponent("connection-token-test-\(UUID().uuidString)", isDirectory: false) - defer { try? FileManager.default.removeItem(at: tokenFileURL) } - try Data("persistenttoken".utf8).write(to: tokenFileURL) - XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path)) - - let controller = VSCodeServeWebController.makeForTesting { _, _ in - XCTFail("Expected no launch") - return nil - } - controller.trackConnectionTokenFileForTesting(tokenFileURL) - - controller.stop() - - XCTAssertTrue( - FileManager.default.fileExists(atPath: tokenFileURL.path), - "Persistent token under Application Support must not be deleted by stop()" - ) - } -} - - final class OmnibarStateMachineTests: XCTestCase { func testEscapeRevertsWhenEditingThenBlursOnSecondEscape() throws { var state = OmnibarState() diff --git a/programaTests/ServeWebPortStoreTests.swift b/programaTests/ServeWebPortStoreTests.swift deleted file mode 100644 index b198f100..00000000 --- a/programaTests/ServeWebPortStoreTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -import XCTest -import Foundation -import Darwin - -#if canImport(Programa_DEV) -@testable import Programa_DEV -#elseif canImport(Programa) -@testable import Programa -#endif - -/// Tests for #21: persisting and reusing the VS Code serve-web port so the embedded -/// browser keeps the same URL across restarts, while falling back to an OS-assigned -/// port when the persisted one is no longer free. -final class ServeWebPortStoreTests: XCTestCase { - private func makeTempDir() -> URL { - let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("serve-web-port-test-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - func testPortArgumentIsZeroWhenNoFile() { - let dir = makeTempDir() - XCTAssertEqual( - ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }), - "0" - ) - } - - func testPersistThenReuseRoundTrips() { - let dir = makeTempDir() - ServeWebPortStore.persist(port: 50_321, in: dir) - XCTAssertEqual( - ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }), - "50321" - ) - } - - func testPersistedPortIgnoredWhenUnavailable() { - let dir = makeTempDir() - ServeWebPortStore.persist(port: 50_321, in: dir) - XCTAssertEqual( - ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in false }), - "0", - "An occupied persisted port must fall back to OS-assigned" - ) - } - - func testNilDirectoryIsZero() { - XCTAssertEqual( - ServeWebPortStore.portArgument(persistedIn: nil, isPortAvailable: { _ in true }), - "0" - ) - } - - func testPersistRejectsOutOfRangePorts() { - let dir = makeTempDir() - ServeWebPortStore.persist(port: 0, in: dir) - ServeWebPortStore.persist(port: 70_000, in: dir) - // Nothing valid was written, so the OS-assigned fallback still applies. - XCTAssertEqual( - ServeWebPortStore.portArgument(persistedIn: dir, isPortAvailable: { _ in true }), - "0" - ) - } - - func testParsePort() { - XCTAssertEqual(ServeWebPortStore.parsePort(" 8080 \n"), 8080) - XCTAssertEqual(ServeWebPortStore.parsePort("1"), 1) - XCTAssertEqual(ServeWebPortStore.parsePort("65535"), 65535) - XCTAssertNil(ServeWebPortStore.parsePort("")) - XCTAssertNil(ServeWebPortStore.parsePort("notaport")) - XCTAssertNil(ServeWebPortStore.parsePort("0")) - XCTAssertNil(ServeWebPortStore.parsePort("65536")) - XCTAssertNil(ServeWebPortStore.parsePort("-1")) - } - - func testIsPortAvailableDetectsOccupiedPort() throws { - // Bind+listen on an OS-assigned loopback port, then assert the store reports it busy. - let listenFd = socket(AF_INET, SOCK_STREAM, 0) - try XCTSkipIf(listenFd < 0, "could not create probe socket") - defer { close(listenFd) } - - var reuse: Int32 = 1 - _ = setsockopt(listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout<Int32>.size)) - - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = 0 // OS-assigned - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - let bound = withUnsafePointer(to: &addr) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - Darwin.bind(listenFd, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size)) - } - } - try XCTSkipIf(bound != 0, "could not bind probe socket") - try XCTSkipIf(listen(listenFd, 1) != 0, "could not listen on probe socket") - - var boundAddr = sockaddr_in() - var len = socklen_t(MemoryLayout<sockaddr_in>.size) - let got = withUnsafeMutablePointer(to: &boundAddr) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - getsockname(listenFd, sockaddrPointer, &len) - } - } - try XCTSkipIf(got != 0, "could not read probe port") - let port = Int(UInt16(bigEndian: boundAddr.sin_port)) - - XCTAssertFalse( - ServeWebPortStore.isPortAvailable(port), - "A port held by a live listener must report as unavailable" - ) - } -} diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index f21d6bc4..8c8f35bd 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -46,32 +46,6 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.panelTitle(panelId: restoredPanelId), "Readme") } - @MainActor - func testSessionSnapshotSkipsTransientRemoteListeningPorts() throws { - let workspace = Workspace() - let panelId = try XCTUnwrap(workspace.focusedPanelId) - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64001, - relayID: "relay-test", - relayToken: String(repeating: "c", count: 64), - localSocketPath: "/tmp/programa-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - workspace.configureRemoteConnection(configuration, autoConnect: false) - workspace.surfaceListeningPorts[panelId] = [6969] - - let snapshot = workspace.sessionSnapshot(includeScrollback: false) - let panelSnapshot = try XCTUnwrap(snapshot.panels.first { $0.id == panelId }) - - XCTAssertTrue(panelSnapshot.listeningPorts.isEmpty) - } - @MainActor func testWorkspaceSessionSnapshotToleratesDuplicatePanelIDs() throws { let workspace = Workspace() diff --git a/programaTests/ShortcutAndCommandPaletteTests.swift b/programaTests/ShortcutAndCommandPaletteTests.swift index 547f20b1..cfc8717f 100644 --- a/programaTests/ShortcutAndCommandPaletteTests.swift +++ b/programaTests/ShortcutAndCommandPaletteTests.swift @@ -59,21 +59,21 @@ final class SplitShortcutTransientFocusGuardTests: XCTestCase { } } -final class ReactGrabShortcutRouteTests: XCTestCase { +final class DesignModeShortcutRouteTests: XCTestCase { func testFocusedBrowserRoutesDirectlyWithoutPasteback() { let browserId = UUID() let terminalId = UUID() - let route = resolveReactGrabShortcutRoute( + let route = resolveDesignModeShortcutRoute( panels: [ - ReactGrabShortcutPanelSnapshot(id: terminalId, panelType: .terminal, isFocused: false), - ReactGrabShortcutPanelSnapshot(id: browserId, panelType: .browser, isFocused: true), + DesignModeShortcutPanelSnapshot(id: terminalId, panelType: .terminal, isFocused: false), + DesignModeShortcutPanelSnapshot(id: browserId, panelType: .browser, isFocused: true), ] ) XCTAssertEqual( route, - ReactGrabShortcutRoute(browserPanelId: browserId, returnTerminalPanelId: nil) + DesignModeShortcutRoute(browserPanelId: browserId, returnTerminalPanelId: nil) ) } @@ -81,25 +81,25 @@ final class ReactGrabShortcutRouteTests: XCTestCase { let browserId = UUID() let terminalId = UUID() - let route = resolveReactGrabShortcutRoute( + let route = resolveDesignModeShortcutRoute( panels: [ - ReactGrabShortcutPanelSnapshot(id: terminalId, panelType: .terminal, isFocused: true), - ReactGrabShortcutPanelSnapshot(id: browserId, panelType: .browser, isFocused: false), + DesignModeShortcutPanelSnapshot(id: terminalId, panelType: .terminal, isFocused: true), + DesignModeShortcutPanelSnapshot(id: browserId, panelType: .browser, isFocused: false), ] ) XCTAssertEqual( route, - ReactGrabShortcutRoute(browserPanelId: browserId, returnTerminalPanelId: terminalId) + DesignModeShortcutRoute(browserPanelId: browserId, returnTerminalPanelId: terminalId) ) } func testFocusedTerminalDoesNotRouteWhenMultipleBrowsersExist() { - let route = resolveReactGrabShortcutRoute( + let route = resolveDesignModeShortcutRoute( panels: [ - ReactGrabShortcutPanelSnapshot(id: UUID(), panelType: .terminal, isFocused: true), - ReactGrabShortcutPanelSnapshot(id: UUID(), panelType: .browser, isFocused: false), - ReactGrabShortcutPanelSnapshot(id: UUID(), panelType: .browser, isFocused: false), + DesignModeShortcutPanelSnapshot(id: UUID(), panelType: .terminal, isFocused: true), + DesignModeShortcutPanelSnapshot(id: UUID(), panelType: .browser, isFocused: false), + DesignModeShortcutPanelSnapshot(id: UUID(), panelType: .browser, isFocused: false), ] ) @@ -107,9 +107,9 @@ final class ReactGrabShortcutRouteTests: XCTestCase { } func testFocusedTerminalDoesNotRouteWithoutBrowser() { - let route = resolveReactGrabShortcutRoute( + let route = resolveDesignModeShortcutRoute( panels: [ - ReactGrabShortcutPanelSnapshot(id: UUID(), panelType: .terminal, isFocused: true), + DesignModeShortcutPanelSnapshot(id: UUID(), panelType: .terminal, isFocused: true), ] ) @@ -119,7 +119,7 @@ final class ReactGrabShortcutRouteTests: XCTestCase { @MainActor -final class ReactGrabPastebackTargetTests: XCTestCase { +final class DesignModePastebackTargetTests: XCTestCase { func testPrefersExplicitTerminalTargetWhenBrowserPanelIsFocused() { let workspace = Workspace(title: "Tests") guard let terminalId = workspace.focusedPanelId else { @@ -167,46 +167,6 @@ final class ReactGrabPastebackTargetTests: XCTestCase { ) } - func testShortcutStillRoutesTerminalPastebackWhenWebViewFocusIsDeferred() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let terminalId = workspace.focusedPanelId, - let browserPanel = workspace.newBrowserSplit( - from: terminalId, - orientation: .horizontal - ) else { - XCTFail("Expected initial workspace with terminal and browser split") - return - } - - workspace.focusPanel(terminalId) - - XCTAssertTrue(manager.toggleReactGrabFromCurrentFocus()) - XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) - XCTAssertEqual(browserPanel.pendingReactGrabReturnTargetPanelId, terminalId) - } - - func testShortcutClearsSplitZoomBeforeRoutingToBrowserPane() { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let terminalId = workspace.focusedPanelId, - let browserPanel = workspace.newBrowserSplit( - from: terminalId, - orientation: .horizontal - ) else { - XCTFail("Expected initial workspace with terminal and browser split") - return - } - - workspace.focusPanel(terminalId) - XCTAssertTrue(workspace.toggleSplitZoom(panelId: terminalId)) - XCTAssertTrue(workspace.bonsplitController.isSplitZoomed) - - XCTAssertTrue(manager.toggleReactGrabFromCurrentFocus()) - XCTAssertFalse(workspace.bonsplitController.isSplitZoomed) - XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) - XCTAssertEqual(browserPanel.pendingReactGrabReturnTargetPanelId, terminalId) - } } diff --git a/programaTests/SidebarOrderingTests.swift b/programaTests/SidebarOrderingTests.swift index 9b7b775a..d781f773 100644 --- a/programaTests/SidebarOrderingTests.swift +++ b/programaTests/SidebarOrderingTests.swift @@ -87,65 +87,6 @@ final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { } -final class SidebarRemoteErrorCopySupportTests: XCTestCase { - func testMenuLabelIsNilWhenThereAreNoErrors() { - XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) - XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) - } - - func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { - let entries = [ - SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox:22", - detail: "failed to start reverse relay" - ) - ] - - XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: entries), - "SSH error (devbox:22): failed to start reverse relay" - ) - } - - func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { - let entries = [ - SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox-a:22", - detail: "connection timed out" - ), - SidebarRemoteErrorCopyEntry( - workspaceTitle: "beta", - target: "devbox-b:22", - detail: "permission denied" - ), - ] - - XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: entries), - """ - 1. alpha (devbox-a:22): connection timed out - 2. beta (devbox-b:22): permission denied - """ - ) - } - - func testClipboardTextSingleEntryUsesStructuredEntryFields() { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: "alpha", - target: "devbox:22", - detail: "failed to bootstrap daemon" - ) - XCTAssertEqual( - SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), - "SSH error (devbox:22): failed to bootstrap daemon" - ) - } -} - final class SidebarWorkspaceHierarchyTests: XCTestCase { func testCollapsedFolderHidesOnlyItsChildren() { let folder = UUID() diff --git a/programaTests/TabManagerSessionSnapshotTests.swift b/programaTests/TabManagerSessionSnapshotTests.swift index 70bd8883..e9f2be5f 100644 --- a/programaTests/TabManagerSessionSnapshotTests.swift +++ b/programaTests/TabManagerSessionSnapshotTests.swift @@ -46,37 +46,4 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(manager.tabs.count, 1) XCTAssertNotNil(manager.selectedTabId) } - - /// Only a *live* remote workspace is excluded from restore -- `remoteConfiguration` alone - /// isn't enough, since it deliberately survives a user-initiated disconnect so Reconnect - /// keeps working (see `Workspace.isLiveRemoteWorkspace`'s doc comment). This test therefore - /// simulates an actually-connected remote session; the disconnected case (which must persist - /// its local panels) is covered by - /// `WorkspaceRemoteConnectionTests.testDisconnectedRemoteWorkspacePersistsLocalPanelsInSessionSnapshot`. - func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws { - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64001, - relayID: "relay-test", - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) - remoteWorkspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: "cmux-macmini") - let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) - _ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false) - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - - XCTAssertEqual(snapshot.workspaces.count, 1) - XCTAssertNil(snapshot.selectedWorkspaceIndex) - XCTAssertFalse(snapshot.workspaces.contains { $0.processTitle == remoteWorkspace.title }) - } } diff --git a/programaTests/TabManagerUnitTests.swift b/programaTests/TabManagerUnitTests.swift index d13ff7e4..bc2fdbb8 100644 --- a/programaTests/TabManagerUnitTests.swift +++ b/programaTests/TabManagerUnitTests.swift @@ -182,89 +182,6 @@ final class TabManagerChildExitCloseTests: XCTestCase { ) } - func testChildExitOnLastRemotePanelKeepsWorkspaceAndDemotesToLocal() throws { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let remotePanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64015, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) - - manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, workspace.id) - XCTAssertEqual(manager.tabs.first?.id, workspace.id) - XCTAssertFalse(workspace.isRemoteWorkspace) - XCTAssertNil(workspace.panels[remotePanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, remotePanelId) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - } - - func testChildExitAfterRemoteSessionEndKeepsWorkspaceAndDemotesToLocal() throws { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let remotePanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64016, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - - workspace.markRemoteTerminalSessionEnded(surfaceId: remotePanelId, relayPort: 64016) - - XCTAssertFalse(workspace.isRemoteWorkspace) - - manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) - drainMainQueue() - drainMainQueue() - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.selectedTabId, workspace.id) - XCTAssertEqual(manager.tabs.first?.id, workspace.id) - XCTAssertFalse(workspace.isRemoteWorkspace) - XCTAssertNil(workspace.panels[remotePanelId]) - XCTAssertEqual(workspace.panels.count, 1) - XCTAssertNotEqual(workspace.focusedPanelId, remotePanelId) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - } - func testChildExitOnNonLastPanelClosesOnlyPanel() { let manager = TabManager() guard let workspace = manager.selectedWorkspace, @@ -669,8 +586,7 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertNotEqual(manager.selectedTabId, backgroundWorkspace.id) // Real git subprocess + GitMetadataProber round trip, not a fixed dispatch - // delay — needs the same headroom as testRemoteSplitSkipsInitialGitMetadataProbe - // below under a full serial suite run's CPU contention. + // delay — needs headroom under a full serial suite run's CPU contention. let branchArrived = waitForCondition(timeout: 12.0) { backgroundWorkspace.panelGitBranches[backgroundPanelId]?.branch == "main" } @@ -802,47 +718,6 @@ final class TabManagerPullRequestProbeTests: XCTestCase { XCTAssertEqual(workspace.sidebarGitBranchesInDisplayOrder().map(\.branch), ["main"]) } - func testRemoteSplitSkipsInitialGitMetadataProbe() throws { - let manager = TabManager() - guard let workspace = manager.selectedWorkspace, - let panelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with focused panel") - return - } - - XCTAssertTrue( - waitForCondition(timeout: 12.0) { - manager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id).isEmpty - } - ) - - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64017, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - - guard let splitPanel = workspace.newTerminalSplit(from: panelId, orientation: .horizontal, focus: false) else { - XCTFail("Expected remote split terminal panel to be created") - return - } - - drainMainQueue() - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertTrue(workspace.isRemoteTerminalSurface(splitPanel.id)) - XCTAssertEqual(manager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id), Set<UUID>()) - } - func testResolvedCommandPathFallsBackOutsideAppPATH() throws { let fileManager = FileManager.default let tempDir = fileManager.temporaryDirectory.appendingPathComponent( @@ -2283,6 +2158,60 @@ final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { XCTAssertTrue(workspace.panels[reopenedPanelId] is BrowserPanel) } + func testOpenCompanionBrowserSplitIfEnabledCreatesUnfocusedSplitWhenSettingOn() { + let suiteName = "AgentBrowserSplitTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + AgentBrowserSplitSettings.setEnabled(true, defaults: defaults) + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedTerminalPanel?.id else { + XCTFail("Expected initial workspace with a focused terminal panel") + return + } + + let splitPanelId = manager.openCompanionBrowserSplitIfEnabled(for: workspace, defaults: defaults) + drainMainQueue() + + XCTAssertNotNil(splitPanelId) + if let splitPanelId { + XCTAssertTrue(workspace.panels[splitPanelId] is BrowserPanel) + } + XCTAssertEqual(workspace.focusedPanelId, terminalPanelId) + } + + func testOpenCompanionBrowserSplitIfEnabledReturnsNilWhenSettingOff() { + let suiteName = "AgentBrowserSplitTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { + defaults.removePersistentDomain(forName: suiteName) + } + AgentBrowserSplitSettings.setEnabled(false, defaults: defaults) + + let manager = TabManager() + guard let workspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + let previousPanelIds = Set(workspace.panels.keys) + + let splitPanelId = manager.openCompanionBrowserSplitIfEnabled(for: workspace, defaults: defaults) + drainMainQueue() + + XCTAssertNil(splitPanelId) + XCTAssertEqual(Set(workspace.panels.keys), previousPanelIds) + XCTAssertFalse(workspace.panels.values.contains { $0 is BrowserPanel }) + } + private func isFocusedPanelBrowser(in workspace: Workspace) -> Bool { guard let focusedPanelId = workspace.focusedPanelId else { return false } return workspace.panels[focusedPanelId] is BrowserPanel diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index 7728c9ae..8b070b7c 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -303,34 +303,12 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) } - func testRemoteImageDropPlanUploadsMaterializedFile() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-drop-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setData(try make1x1PNG(color: .green), forType: .png) - - let plan = GhosttyNSView.dropPlanForTesting( - pasteboard: pasteboard, - isRemoteTerminalSurface: true - ) - - guard case .uploadFiles(let urls) = plan else { - return XCTFail("expected remote upload plan, got \(plan)") - } - defer { urls.forEach { try? FileManager.default.removeItem(at: $0) } } - - XCTAssertEqual(urls.count, 1) - XCTAssertEqual(urls[0].pathExtension, "png") - } - func testLocalImageDropPlanInsertsEscapedLocalPath() throws { let pasteboard = NSPasteboard(name: .init("cmux-test-local-drop-\(UUID().uuidString)")) pasteboard.clearContents() pasteboard.setData(try make1x1PNG(color: .orange), forType: .png) - let plan = GhosttyNSView.dropPlanForTesting( - pasteboard: pasteboard, - isRemoteTerminalSurface: false - ) + let plan = GhosttyNSView.dropPlanForTesting(pasteboard: pasteboard) guard case .insertText(let text) = plan else { return XCTFail("expected local insert plan, got \(plan)") @@ -344,128 +322,14 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: localPath)) } - func testRemoteImagePastePlanUploadsMaterializedFile() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-paste-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setData(try make1x1PNG(color: .cyan), forType: .png) - - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - target: .remote(.workspaceRemote) - ) - - guard case .uploadFiles(let urls, .workspaceRemote) = plan else { - return XCTFail("expected workspace upload plan, got \(plan)") - } - defer { urls.forEach { try? FileManager.default.removeItem(at: $0) } } - - XCTAssertEqual(urls.count, 1) - XCTAssertEqual(urls[0].pathExtension, "png") - } - - func testRemoteFileURLPastePlanUploadsReadableFile() throws { - let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png") - try make1x1PNG(color: .systemPink).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-file-url-paste-\(UUID().uuidString)")) - pasteboard.clearContents() - XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL])) - - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - target: .remote(.workspaceRemote) - ) - - guard case .uploadFiles(let urls, .workspaceRemote) = plan else { - return XCTFail("expected workspace upload plan, got \(plan)") - } - - XCTAssertEqual(urls, [fileURL]) - } - - func testRemoteDirectoryPastePlanFallsBackToEscapedPathInsertion() throws { - let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent( - "clipboard-folder-\(UUID().uuidString)", - isDirectory: true - ) - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directoryURL) } - - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-directory-paste-\(UUID().uuidString)")) - pasteboard.clearContents() - XCTAssertTrue(pasteboard.writeObjects([directoryURL as NSURL])) - - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - target: .remote(.workspaceRemote) - ) - - guard case .insertText(let text) = plan else { - return XCTFail("expected directory path insertion, got \(plan)") - } - - XCTAssertEqual(text, TerminalImageTransferPlanner.escapeForShell(directoryURL.path)) - } - - func testLazyPastePlanSkipsTargetResolutionForPlainText() { - let pasteboard = NSPasteboard(name: .init("cmux-test-lazy-text-paste-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString("hello from clipboard", forType: .string) - - var targetResolutionCount = 0 - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - resolveTarget: { - targetResolutionCount += 1 - return .remote(.workspaceRemote) - } - ) - - XCTAssertEqual(plan, .insertText("hello from clipboard")) - XCTAssertEqual(targetResolutionCount, 0) - } - - func testLazyPastePlanResolvesTargetForFileURLPaste() throws { - let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-image-\(UUID().uuidString).png") - try make1x1PNG(color: .systemTeal).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let pasteboard = NSPasteboard(name: .init("cmux-test-lazy-file-paste-\(UUID().uuidString)")) - pasteboard.clearContents() - XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL])) - - var targetResolutionCount = 0 - let plan = TerminalImageTransferPlanner.plan( - pasteboard: pasteboard, - mode: .paste, - resolveTarget: { - targetResolutionCount += 1 - return .remote(.workspaceRemote) - } - ) - - guard case .uploadFiles(let urls, .workspaceRemote) = plan else { - return XCTFail("expected workspace upload plan, got \(plan)") - } - - XCTAssertEqual(urls, [fileURL]) - XCTAssertEqual(targetResolutionCount, 1) - } - func testLocalImagePastePlanInsertsEscapedLocalPath() throws { let pasteboard = NSPasteboard(name: .init("cmux-test-local-paste-\(UUID().uuidString)")) pasteboard.clearContents() pasteboard.setData(try make1x1PNG(color: .magenta), forType: .png) - let plan = TerminalImageTransferPlanner.plan( + let plan = TerminalPasteboardPlanner.plan( pasteboard: pasteboard, - mode: .paste, - target: .local + mode: .paste ) guard case .insertText(let text) = plan else { @@ -480,198 +344,15 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: localPath)) } - func testRemoteImagePasteExecutionUploadsAndCompletesWithRemotePath() throws { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-test.png") - try make1x1PNG(color: .yellow).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - var completedText: String? - - TerminalImageTransferPlanner.executeForTesting( - plan: .uploadFiles([url], .workspaceRemote), - uploadWorkspaceRemote: { _, _, finish in finish(.success(["/tmp/programa-drop-123.png"])) }, - uploadDetectedSSH: { _, _, _, finish in finish(.failure(NSError(domain: "unused", code: 0))) }, - insertText: { completedText = $0 }, - onFailure: { _ in XCTFail("unexpected failure") } - ) - - XCTAssertEqual(completedText, "/tmp/programa-drop-123.png") - } - - func testCancelledRemoteImagePasteExecutionSuppressesCompletionHandlers() throws { - let url = FileManager.default.temporaryDirectory.appendingPathComponent("clipboard-cancel-test.png") - try make1x1PNG(color: .brown).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - let operation = TerminalImageTransferOperation() - var completion: ((Result<[String], Error>) -> Void)? - var cancellationHandlerCalls = 0 - var insertedTexts: [String] = [] - var failureCount = 0 - - let returnedOperation = TerminalImageTransferPlanner.executeForTesting( - plan: .uploadFiles([url], .workspaceRemote), - operation: operation, - uploadWorkspaceRemote: { _, operation, finish in - operation.installCancellationHandler { - cancellationHandlerCalls += 1 - } - completion = finish - }, - uploadDetectedSSH: { _, _, _, finish in - finish(.failure(NSError(domain: "unused", code: 0))) - }, - insertText: { insertedTexts.append($0) }, - onFailure: { _ in failureCount += 1 } - ) - - XCTAssertTrue(returnedOperation === operation) - XCTAssertTrue(operation.cancel()) - completion?(.success(["/tmp/programa-drop-cancelled.png"])) - - XCTAssertEqual(cancellationHandlerCalls, 1) - XCTAssertTrue(insertedTexts.isEmpty) - XCTAssertEqual(failureCount, 0) - } - - func testCancelledOperationSuppressesLateLocalInsert() { - let operation = TerminalImageTransferOperation() - var insertedTexts: [String] = [] - var failureCount = 0 - - XCTAssertTrue(operation.cancel()) - - let returnedOperation = TerminalImageTransferPlanner.executeForTesting( - plan: .insertText("/tmp/programa-drop-local.png"), - operation: operation, - uploadWorkspaceRemote: { _, _, finish in - finish(.failure(NSError(domain: "unused", code: 0))) - }, - uploadDetectedSSH: { _, _, _, finish in - finish(.failure(NSError(domain: "unused", code: 0))) - }, - insertText: { insertedTexts.append($0) }, - onFailure: { _ in failureCount += 1 } - ) - - XCTAssertTrue(returnedOperation === operation) - XCTAssertTrue(insertedTexts.isEmpty) - XCTAssertEqual(failureCount, 0) - } - - func testRemoteUploadResultEscapesSpacesBeforePaste() { - let escaped = TerminalImageTransferPlanner.escapeForShell("/tmp/Screen Shot.png") + func testInsertedPathEscapesSpacesBeforePaste() { + let escaped = TerminalPasteboardPlanner.escapeForShell("/tmp/Screen Shot.png") XCTAssertEqual(escaped, "/tmp/Screen\\ Shot.png") } - func testRemoteUploadResultSingleQuotesEmbeddedNewlinesBeforePaste() { - let escaped = TerminalImageTransferPlanner.escapeForShell("/tmp/Screen\nShot\r.png") + func testInsertedPathSingleQuotesEmbeddedNewlinesBeforePaste() { + let escaped = TerminalPasteboardPlanner.escapeForShell("/tmp/Screen\nShot\r.png") XCTAssertEqual(escaped, "'/tmp/Screen\nShot\r.png'") } - - func testRemoteImageDropHandlerUploadsAndSendsRemotePath() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setData(try make1x1PNG(color: .purple), forType: .png) - - var uploadedURLs: [URL] = [] - var sentText: [String] = [] - var failureCount = 0 - - let handled = GhosttyNSView.handleDropForTesting( - pasteboard: pasteboard, - isRemoteTerminalSurface: true, - uploadRemote: { urls, finish in - uploadedURLs = urls - finish(.success(["/tmp/programa-drop-abc123.png"])) - }, - sendText: { sentText.append($0) }, - onFailure: { failureCount += 1 } - ) - defer { uploadedURLs.forEach { try? FileManager.default.removeItem(at: $0) } } - - XCTAssertTrue(handled) - XCTAssertEqual(uploadedURLs.count, 1) - XCTAssertEqual(sentText, ["/tmp/programa-drop-abc123.png"]) - XCTAssertEqual(failureCount, 0) - } - - func testRemoteImageDropHandlerCleansUpMaterializedTemporaryImageAfterSuccess() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-cleanup-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setData(try make1x1PNG(color: .orange), forType: .png) - - var uploadedURL: URL? - - let handled = GhosttyNSView.handleDropForTesting( - pasteboard: pasteboard, - isRemoteTerminalSurface: true, - uploadRemote: { urls, finish in - uploadedURL = urls.first - XCTAssertEqual(urls.count, 1) - XCTAssertTrue(FileManager.default.fileExists(atPath: urls[0].path)) - finish(.success(["/tmp/programa-drop-abc123.png"])) - }, - sendText: { _ in }, - onFailure: {} - ) - - XCTAssertTrue(handled) - let url = try XCTUnwrap(uploadedURL) - XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) - } - - func testRemoteDropUploadFailureTriggersFailureHandler() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-fail-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setData(try make1x1PNG(color: .black), forType: .png) - - var uploadedURLs: [URL] = [] - var sentText: [String] = [] - var failureCount = 0 - - let handled = GhosttyNSView.handleDropForTesting( - pasteboard: pasteboard, - isRemoteTerminalSurface: true, - uploadRemote: { urls, finish in - uploadedURLs = urls - finish(.failure(NSError(domain: "test", code: 1))) - }, - sendText: { sentText.append($0) }, - onFailure: { failureCount += 1 } - ) - defer { uploadedURLs.forEach { try? FileManager.default.removeItem(at: $0) } } - - XCTAssertTrue(handled) - XCTAssertEqual(uploadedURLs.count, 1) - XCTAssertTrue(sentText.isEmpty) - XCTAssertEqual(failureCount, 1) - } - - func testRemoteImageDropHandlerCleansUpMaterializedTemporaryImageAfterFailure() throws { - let pasteboard = NSPasteboard(name: .init("cmux-test-remote-handler-failure-cleanup-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setData(try make1x1PNG(color: .cyan), forType: .png) - - var uploadedURL: URL? - - let handled = GhosttyNSView.handleDropForTesting( - pasteboard: pasteboard, - isRemoteTerminalSurface: true, - uploadRemote: { urls, finish in - uploadedURL = urls.first - XCTAssertEqual(urls.count, 1) - XCTAssertTrue(FileManager.default.fileExists(atPath: urls[0].path)) - finish(.failure(NSError(domain: "test", code: 1))) - }, - sendText: { _ in XCTFail("unexpected sendText") }, - onFailure: {} - ) - - XCTAssertTrue(handled) - let url = try XCTUnwrap(uploadedURL) - XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) - } } @@ -1305,7 +986,6 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { TerminalDirectoryOpenTarget.DetectionEnvironment( homeDirectoryPath: homeDirectoryPath, fileExistsAtPath: { existingPaths.contains($0) }, - isExecutableFileAtPath: { existingPaths.contains($0) }, applicationPathForBundleIdentifier: { applicationPathsByBundleIdentifier[$0] } ) } @@ -1345,12 +1025,6 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { XCTAssertFalse(availableTargets.contains(.vscode)) } - func testVSCodeInlineRequiresCodeTunnelExecutable() { - let env = environment(existingPaths: ["/Applications/Visual Studio Code.app"]) - XCTAssertTrue(TerminalDirectoryOpenTarget.vscode.isAvailable(in: env)) - XCTAssertFalse(TerminalDirectoryOpenTarget.vscodeInline.isAvailable(in: env)) - } - func testITerm2DetectsLegacyBundleName() { let env = environment(existingPaths: ["/Applications/iTerm.app"]) XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) @@ -1375,7 +1049,6 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) XCTAssertTrue(availableTargets.contains(.vscode)) - XCTAssertTrue(availableTargets.contains(.vscodeInline)) } func testTowerDetectedViaApplicationLookupOutsideApplications() { @@ -4956,56 +4629,6 @@ final class TerminalControllerV2BrowserStateRestoreTests: XCTestCase { coordinator.release(secondLease) } - func testRemoteProxyRestorePreservesLogicalOriginAndVerifiesAliasExecutionOrigin() throws { - let logicalURLs = try [ - "http://127.0.0.1:3000/state", - "http://[::1]:3000/state", - "http://0.0.0.0:3000/state", - "http://localhost:3000/state", - ].map { try XCTUnwrap(URL(string: $0)) } - - for logicalURL in logicalURLs { - let aliasURL = try XCTUnwrap(BrowserPanel.remoteProxyLoopbackAliasURL(for: logicalURL)) - let recorder = RestoreRecorder() - let navigationID = UUID() - recorder.navigationOutcome = .finished( - committed: .init( - navigationID: navigationID, - url: logicalURL, - executionURL: aliasURL - ), - finished: .init( - navigationID: navigationID, - url: logicalURL, - executionURL: aliasURL - ) - ) - let fileURL = try stateFile(object: validState( - url: logicalURL.absoluteString, - localStorage: [:], - sessionStorage: [:], - frameSelector: nil - )) - - switch restore(fileURL, recorder: recorder) { - case .success: - break - case .failure(let failure): - XCTFail("Remote loopback restore must preserve \(logicalURL): \(failure)") - } - let payload = try XCTUnwrap(recorder.capturedStorage) - XCTAssertEqual( - payload.expectedOrigin, - TerminalController.V2BrowserStateRestorer.originString(for: logicalURL) - ) - XCTAssertEqual( - payload.executionOrigin, - TerminalController.V2BrowserStateRestorer.originString(for: aliasURL), - "JavaScript must verify the effective proxy alias while the saved state retains the requested loopback host" - ) - } - } - func testDuplicateCookieIdentitiesAreRejectedBeforeConcurrentCookieWrites() throws { let duplicateCookies: [[String: Any]] = [ ["name": "session", "value": "first", "domain": "example.com", "path": "/"], diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index e8af5f49..4c95eea4 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -1115,34 +1115,6 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } #endif - func testMobileBridgePingSucceedsWhileUnixSocketControlIsStopped() throws { - TerminalController.shared.stop() - - let response = try sendPingThroughMobileBridgeHandler(id: 1) - - XCTAssertTrue( - isSuccessfulV2Ping(response), - "Stopping Unix Socket Control must not disable an independently admitted Mobile Bridge session" - ) - } - - func testMobileBridgePingSucceedsWhileUnixSocketControlRequiresPassword() throws { - let socketPath = makeSocketPath("mobile-password") - TerminalController.shared.start( - tabManager: TabManager(), - socketPath: socketPath, - accessMode: .password - ) - try waitForSocket(at: socketPath) - - let response = try sendPingThroughMobileBridgeHandler(id: 1) - - XCTAssertTrue( - isSuccessfulV2Ping(response), - "Unix Socket Control password policy must not leak into an independently admitted Mobile Bridge session" - ) - } - func testSocketCommandPolicyDistinguishesFocusIntent() throws { #if DEBUG // The v1 line protocol was removed: isV2: false is now unreachable from any real @@ -1219,33 +1191,6 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { #endif } - func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() { - let tabManager = TabManager() - let workspace = tabManager.addWorkspace(select: false, eagerLoadTerminal: false) - - workspace.configureRemoteConnection( - .init( - destination: "example.com", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: ["ControlMaster=auto", "ControlPersist=600"], - localProxyPort: 1080, - relayPort: 4444, - relayID: "relay-id", - relayToken: "relay-token", - localSocketPath: "/tmp/programa-test.sock", - terminalStartupCommand: "ssh example.com" - ), - autoConnect: false - ) - - let payload = workspace.remoteStatusPayload() - XCTAssertNil(payload["identity_file"]) - XCTAssertNil(payload["ssh_options"]) - XCTAssertEqual(payload["has_identity_file"] as? Bool, true) - XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) - } - func testNotificationCreateUsesExplicitSurfaceIDWhenProvided() async throws { let socketPath = makeSocketPath("notify-surface") let store = TerminalNotificationStore.shared @@ -2826,17 +2771,6 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(workspace.agentPIDs["agent-0"], pid_t.max) XCTAssertEqual(workspace.agentPIDs.count, SidebarTelemetryLimits.maxAgentPIDs) - let exactDiagnostic = String(repeating: "e", count: SidebarTelemetryLimits.maxLogMessageBytes) - workspace.applyRemoteConnectionStateUpdate(.connecting, detail: exactDiagnostic, target: "example") - XCTAssertEqual(workspace.remoteConnectionDetail, exactDiagnostic) - workspace.applyRemoteConnectionStateUpdate(.connecting, detail: exactDiagnostic + "e", target: "example") - XCTAssertEqual(workspace.remoteConnectionDetail, exactDiagnostic) - - workspace.applyRemoteDaemonStatusUpdate( - WorkspaceRemoteDaemonStatus(state: .ready, detail: exactDiagnostic + "e"), - target: "example" - ) - XCTAssertEqual(workspace.remoteDaemonStatus.detail, exactDiagnostic) } func testNewAgentRefreshInvalidatesResultsAlreadyValidatedForPublication() async { @@ -3156,47 +3090,6 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { return error["code"] as? String } - private nonisolated func sendPingThroughMobileBridgeHandler(id: Int) throws -> [String: Any] { - let method = "system.ping" - guard MobileBridgeMethodAllowList.isAllowed(method) else { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENOTSUP), userInfo: [ - NSLocalizedDescriptionKey: "system.ping is not admitted by the Mobile Bridge method allow-list" - ]) - } - - var sockets: [Int32] = [-1, -1] - let socketPairResult = sockets.withUnsafeMutableBufferPointer { buffer in - Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, buffer.baseAddress) - } - guard socketPairResult == 0 else { - throw posixError("socketpair(AF_UNIX)") - } - - let localFD = sockets[0] - let handlerFD = sockets[1] - do { - try suppressSIGPIPE(on: localFD) - } catch { - Darwin.close(localFD) - Darwin.close(handlerFD) - throw error - } - defer { - _ = Darwin.shutdown(localFD, SHUT_RDWR) - Darwin.close(localFD) - } - - Thread.detachNewThread { - TerminalController.shared.handleClient( - handlerFD, - peerPid: getpid(), - source: .mobileBridge - ) - } - - return try sendV2Ping(to: localFD, id: id) - } - private nonisolated func writeLine(_ command: String, to fd: Int32) throws { let payload = Array((command + "\n").utf8) var offset = 0 diff --git a/programaTests/WorkspaceRemoteConnectionTests.swift b/programaTests/WorkspaceRemoteConnectionTests.swift deleted file mode 100644 index e1cfe58c..00000000 --- a/programaTests/WorkspaceRemoteConnectionTests.swift +++ /dev/null @@ -1,3389 +0,0 @@ -import XCTest -import Bonsplit - -#if canImport(Programa_DEV) -@testable import Programa_DEV -#elseif canImport(Programa) -@testable import Programa -#endif - -final class WorkspaceRemoteConnectionTests: XCTestCase { - private struct ProcessRunResult { - let status: Int32 - let stdout: String - let stderr: String - let timedOut: Bool - } - - private func runProcess( - executablePath: String, - arguments: [String], - timeout: TimeInterval - ) -> ProcessRunResult { - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.standardInput = FileHandle.nullDevice - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - // Signalled from Foundation's own termination callback rather than by parking a - // `DispatchQueue.global()` worker in `waitUntilExit()`. The old shape blocked one - // pooled thread for the entire lifetime of every child process; run enough of these - // concurrently (the suite runs in parallel) and the global pool saturates, so the - // block that signals this semaphore can sit unscheduled long past the child's actual - // exit. The test then reports a timeout at exactly its configured limit for a - // subprocess that finished in milliseconds -- observed repeatedly on CI as 90s - // failures of scripts that run in ~15ms locally. `terminationHandler` is delivered on - // Foundation's private queue and blocks nothing. Must be set before `run()`. - let exitSignal = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in exitSignal.signal() } - - do { - try process.run() - } catch { - return ProcessRunResult( - status: -1, - stdout: "", - stderr: String(describing: error), - timedOut: false - ) - } - - let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut - if timedOut { - process.terminate() - _ = exitSignal.wait(timeout: .now() + 1) - } - - let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - return ProcessRunResult( - status: process.terminationStatus, - stdout: stdout, - stderr: stderr, - timedOut: timedOut - ) - } - - private func writeShellFile(at url: URL, lines: [String]) throws { - try lines.joined(separator: "\n") - .appending("\n") - .write(to: url, atomically: true, encoding: .utf8) - } - - private func runRelayZshHistfile( - configureUserHome: (URL) throws -> URL - ) throws -> String { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-zsh-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".programa/relay/64011.shell") - - try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: home) } - - let effectiveUserZdotdir = try configureUserHome(home) - let bootstrap = RemoteRelayZshBootstrap(shellStateDir: relayDir.path) - - try writeShellFile(at: relayDir.appendingPathComponent(".zshenv"), lines: bootstrap.zshEnvLines) - try writeShellFile(at: relayDir.appendingPathComponent(".zprofile"), lines: bootstrap.zshProfileLines) - try writeShellFile(at: relayDir.appendingPathComponent(".zshrc"), lines: bootstrap.zshRCLines(commonShellLines: [])) - try writeShellFile(at: relayDir.appendingPathComponent(".zlogin"), lines: bootstrap.zshLoginLines) - - let result = runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "TERM=xterm-256color", - "SHELL=/bin/zsh", - "USER=\(NSUserName())", - "PROGRAMA_REAL_ZDOTDIR=\(home.path)", - "ZDOTDIR=\(relayDir.path)", - "/bin/zsh", - "-ilc", - "print -r -- \"$HISTFILE\"", - ], - // Real /bin/zsh subprocess spawn + shell startup, not a fixed in-test dispatch - // delay. Under a full parallel suite run with heavy CPU contention from - // hundreds of prior tests (some spawning their own subprocesses), process - // scheduling and shell startup can legitimately take longer than 5s. - // - // This is a ceiling, not a sleep: the wait returns the moment the process - // exits, so a generous value costs nothing when the test passes and only - // affects how long a genuinely hung process takes to report. It was 20s and - // still timed out at 21.2s on a loaded runner (CI run 30122626927), taking a - // release with it — so size it far past plausible contention rather than - // just past the last observed failure. - timeout: 90 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - - let histfile = result.stdout - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .last(where: { !$0.isEmpty }) - XCTAssertEqual(histfile, effectiveUserZdotdir.appendingPathComponent(".zsh_history").path) - return histfile ?? "" - } - - func testRemoteDaemonDownloadIsMovedIntoOwnedStorageBeforeCallbackReturns() throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory - .appendingPathComponent("remote-daemon-download-\(UUID().uuidString)", isDirectory: true) - let callbackTemporaryURL = directory.appendingPathComponent("urlsession.tmp") - let ownedURL = directory.appendingPathComponent("owned.download") - defer { try? fileManager.removeItem(at: directory) } - - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - try Data("verified daemon".utf8).write(to: callbackTemporaryURL) - - let result = WorkspaceRemoteSessionController.preserveRemoteDaemonDownload( - temporaryURL: callbackTemporaryURL, - response: nil, - error: nil, - destinationURL: ownedURL, - fileManager: fileManager - ) - - XCTAssertEqual(try result.get(), ownedURL) - XCTAssertFalse(fileManager.fileExists(atPath: callbackTemporaryURL.path)) - XCTAssertEqual(try Data(contentsOf: ownedURL), Data("verified daemon".utf8)) - } - - func testRemoteDaemonDownloadRejectsHTTPFailureWithoutMovingTemporaryFile() throws { - let fileManager = FileManager.default - let directory = fileManager.temporaryDirectory - .appendingPathComponent("remote-daemon-http-\(UUID().uuidString)", isDirectory: true) - let callbackTemporaryURL = directory.appendingPathComponent("urlsession.tmp") - let ownedURL = directory.appendingPathComponent("owned.download") - defer { try? fileManager.removeItem(at: directory) } - - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) - try Data("error page".utf8).write(to: callbackTemporaryURL) - let response = try XCTUnwrap(HTTPURLResponse( - url: URL(string: "https://example.invalid/programad-remote")!, - statusCode: 503, - httpVersion: nil, - headerFields: nil - )) - - let result = WorkspaceRemoteSessionController.preserveRemoteDaemonDownload( - temporaryURL: callbackTemporaryURL, - response: response, - error: nil, - destinationURL: ownedURL, - fileManager: fileManager - ) - - XCTAssertThrowsError(try result.get()) - XCTAssertTrue(fileManager.fileExists(atPath: callbackTemporaryURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: ownedURL.path)) - } - - @MainActor - func testStoppedRelayCannotAppendStderrIntoReplacementGeneration() { - let workspace = Workspace() - let configuration = WorkspaceRemoteConfiguration( - destination: "example.invalid", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64_007, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-relay-stderr-test.sock", - terminalStartupCommand: "ssh example.invalid" - ) - let controller = WorkspaceRemoteSessionController( - workspace: workspace, - configuration: configuration, - controllerID: UUID() - ) - let oldPipe = Pipe() - let replacementPipe = Pipe() - - controller.reverseRelayGeneration = 10 - controller.reverseRelayStderrPipe = replacementPipe - controller.reverseRelayStderrBuffer = "replacement:" - - XCTAssertFalse( - controller.appendReverseRelayStderrLocked( - Data("old process failure".utf8), - from: oldPipe, - generation: 9 - ) - ) - XCTAssertEqual(controller.reverseRelayStderrBuffer, "replacement:") - XCTAssertTrue( - controller.appendReverseRelayStderrLocked( - Data(" current process failure".utf8), - from: replacementPipe, - generation: 10 - ) - ) - XCTAssertEqual( - controller.reverseRelayStderrBuffer, - "replacement: current process failure" - ) - } - - func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".programa/relay") - let socketAddrURL = home.appendingPathComponent(".programa/socket_addr") - let authURL = relayDir.appendingPathComponent("64008.auth") - let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") - let ttyURL = relayDir.appendingPathComponent("64008.tty") - - XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) - XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) - XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) - XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) - XCTAssertNoThrow(try "ttys001".write(to: ttyURL, atomically: true, encoding: .utf8)) - defer { try? fileManager.removeItem(at: home) } - - let result = runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "/bin/sh", - "-c", - WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64008), - ], - // See timeout comment in runRelayZshHistfile above: real subprocess spawn, - // not a fixed dispatch delay — needs headroom under full-suite CPU load. - timeout: 90 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) - } - - func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)") - let relayDir = home.appendingPathComponent(".programa/relay") - let socketAddrURL = home.appendingPathComponent(".programa/socket_addr") - let authURL = relayDir.appendingPathComponent("64009.auth") - let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") - let ttyURL = relayDir.appendingPathComponent("64009.tty") - - XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) - XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) - XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) - XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) - XCTAssertNoThrow(try "ttys002".write(to: ttyURL, atomically: true, encoding: .utf8)) - defer { try? fileManager.removeItem(at: home) } - - let result = runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "/bin/sh", - "-c", - WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64009), - ], - // See timeout comment in runRelayZshHistfile above: real subprocess spawn, - // not a fixed dispatch delay — needs headroom under full-suite CPU load. - timeout: 90 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) - } - - func testRemoteDaemonPruneStaleVersionsScriptKeepsCurrentAndNewestOtherByMtime() throws { - let fileManager = FileManager.default - let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-daemon-prune-\(UUID().uuidString)") - let daemonBase = home.appendingPathComponent(".programa/bin/programad-remote") - defer { try? fileManager.removeItem(at: home) } - - let currentVersion = "0.4.213" - // "0.4.100" is deliberately the most-recently-used "other" version by mtime - // while being lexically *smaller* than "0.4.99" -- this proves retention is - // decided by directory mtime, not by comparing version strings, since patch - // is a CI run number (0.4.9 vs 0.4.100 sorts backwards lexically). - let baseDate = Date(timeIntervalSince1970: 1_700_000_000) - let mtimeOffsetByVersion: [String: TimeInterval] = [ - "0.4.9": 100, - "0.4.99": 200, - "0.4.100": 300, - currentVersion: 400, - ] - - var versionDirectories: [String: URL] = [:] - for (version, offset) in mtimeOffsetByVersion { - let platformDir = daemonBase.appendingPathComponent(version).appendingPathComponent("darwin-arm64") - try fileManager.createDirectory(at: platformDir, withIntermediateDirectories: true) - try "binary".write(to: platformDir.appendingPathComponent("programad-remote"), atomically: true, encoding: .utf8) - let versionDir = daemonBase.appendingPathComponent(version) - try fileManager.setAttributes( - [.modificationDate: baseDate.addingTimeInterval(offset)], - ofItemAtPath: versionDir.path - ) - versionDirectories[version] = versionDir - } - - // Sibling outside programad-remote/ that must never be touched, even though - // it shares the parent "bin" directory the prune script also lives under. - let decoySibling = home.appendingPathComponent(".programa/bin/decoy-outside-programad-remote") - try fileManager.createDirectory(at: decoySibling, withIntermediateDirectories: true) - - let script = WorkspaceRemoteSessionController.remoteDaemonPruneStaleVersionsScript(currentVersion: currentVersion) - - // Generated-artifact assertion: the script is the runtime behavior here, so - // assert the deletion is anchored to the literal expanded base directory and - // that no unanchored `rm -rf` on the base (or anything broader) exists. - XCTAssertTrue(script.contains(#"case "$programa_version_dir" in"#)) - XCTAssertTrue(script.contains(#""$programa_daemon_base"/*)"#)) - XCTAssertFalse(script.contains("rm -rf \"$programa_daemon_base\"")) - XCTAssertFalse(script.contains("rm -rf -- \"$programa_daemon_base\"\n")) - - let result = runProcess( - executablePath: "/usr/bin/env", - arguments: [ - "HOME=\(home.path)", - "/bin/sh", - "-c", - script, - ], - // See timeout comment in runRelayZshHistfile above. - timeout: 90 - ) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - - XCTAssertTrue( - fileManager.fileExists(atPath: try XCTUnwrap(versionDirectories[currentVersion]).path), - "current version directory must survive" - ) - XCTAssertTrue( - fileManager.fileExists(atPath: try XCTUnwrap(versionDirectories["0.4.100"]).path), - "most-recently-used other version directory must survive" - ) - XCTAssertFalse( - fileManager.fileExists(atPath: try XCTUnwrap(versionDirectories["0.4.99"]).path), - "stale version directory must be pruned" - ) - XCTAssertFalse( - fileManager.fileExists(atPath: try XCTUnwrap(versionDirectories["0.4.9"]).path), - "stale version directory must be pruned" - ) - XCTAssertTrue( - fileManager.fileExists(atPath: decoySibling.path), - "prune must never touch anything outside programad-remote/" - ) - } - - func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws { - let histfile = try runRelayZshHistfile { home in - try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) - try ":\n".write(to: home.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - return home - } - - XCTAssertTrue(histfile.hasSuffix("/.zsh_history")) - } - - func testRelayZshBootstrapUsesUserUpdatedZdotdirHistory() throws { - let histfile = try runRelayZshHistfile { home in - let altZdotdir = home.appendingPathComponent("dotfiles") - try FileManager.default.createDirectory(at: altZdotdir, withIntermediateDirectories: true) - try "export ZDOTDIR=\"$HOME/dotfiles\"\n".write( - to: home.appendingPathComponent(".zshenv"), - atomically: true, - encoding: .utf8 - ) - try ":\n".write(to: altZdotdir.appendingPathComponent(".zshrc"), atomically: true, encoding: .utf8) - return altZdotdir - } - - XCTAssertTrue(histfile.contains("/dotfiles/.zsh_history")) - } - - func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws { - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "echo 'remote port forwarding failed for listen port 64009' >&2; exit 1"] - process.standardInput = FileHandle.nullDevice - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe - - try process.run() - - let detail = WorkspaceRemoteSessionController.reverseRelayStartupFailureDetail( - process: process, - stderrPipe: stderrPipe, - gracePeriod: 1.0 - ) - - XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009") - } - - func testExecutableSearchPathsIncludesHomebrewAndHomeFallbacks() { - let paths = WorkspaceRemoteSessionController.executableSearchPaths( - environment: [ - "HOME": "/Users/tester", - "PATH": "/usr/bin:/bin", - ], - pathHelperOutput: "PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin\"; export PATH;\n" - ) - - XCTAssertEqual( - paths, - [ - "/usr/bin", - "/bin", - "/Users/tester/.local/bin", - "/Users/tester/go/bin", - "/Users/tester/bin", - "/opt/homebrew/bin", - "/usr/local/bin", - "/opt/homebrew/sbin", - "/usr/local/sbin", - "/usr/sbin", - "/sbin", - ] - ) - } - - func testParsePathHelperPathsExtractsPathEntries() { - XCTAssertEqual( - WorkspaceRemoteSessionController.parsePathHelperPaths( - "PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin\"; export PATH;\n" - ), - [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - ] - ) - } - - func testParsePathHelperPathsIgnoresMANPATHAssignments() { - XCTAssertEqual( - WorkspaceRemoteSessionController.parsePathHelperPaths( - """ - MANPATH="/opt/homebrew/share/man:/usr/share/man"; export MANPATH; - PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin"; export PATH; - """ - ), - [ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - ] - ) - } - - @MainActor - func testRemoteTerminalSurfaceLookupTracksOnlyActiveSSHSurfaces() throws { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64007, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - workspace.configureRemoteConnection(config, autoConnect: false) - - let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) - XCTAssertTrue(workspace.isRemoteTerminalSurface(panelID)) - - workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64007) - XCTAssertFalse(workspace.isRemoteTerminalSurface(panelID)) - } - - @MainActor - func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64029, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini", - foregroundAuthToken: "token-a" - ) - - workspace.notifyRemoteForegroundAuthenticationReady(token: "token-a") - workspace.configureRemoteConnection(config, autoConnect: false) - - XCTAssertEqual(workspace.remoteConnectionState, .connecting) - workspace.disconnectRemoteConnection(clearConfiguration: true) - } - - @MainActor - func testForegroundSSHAuthReadyReconnectsConfiguredDisconnectedRemoteWorkspace() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64030, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini", - foregroundAuthToken: "token-a" - ) - - workspace.configureRemoteConnection(config, autoConnect: false) - XCTAssertEqual(workspace.remoteConnectionState, .disconnected) - - workspace.notifyRemoteForegroundAuthenticationReady(token: "token-a") - - XCTAssertEqual(workspace.remoteConnectionState, .connecting) - workspace.disconnectRemoteConnection(clearConfiguration: true) - } - - @MainActor - func testForegroundSSHAuthReadyBufferedTokenDoesNotReconnectDifferentConfiguration() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64031, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini", - foregroundAuthToken: "token-b" - ) - - workspace.notifyRemoteForegroundAuthenticationReady(token: "token-a") - workspace.configureRemoteConnection(config, autoConnect: false) - - XCTAssertEqual(workspace.remoteConnectionState, .disconnected) - } - - @MainActor - func testForegroundSSHAuthReadyIgnoresMismatchedConfiguredToken() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64032, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini", - foregroundAuthToken: "token-a" - ) - - workspace.configureRemoteConnection(config, autoConnect: false) - workspace.notifyRemoteForegroundAuthenticationReady(token: "token-b") - - XCTAssertEqual(workspace.remoteConnectionState, .disconnected) - } - - /// Regression for the "disconnect a remote workspace, keep using it locally, lose every - /// terminal in it at the next restart" incident: `remoteConfiguration` intentionally - /// survives a user-initiated disconnect (see `disconnectRemoteConnection`'s doc comment) so - /// `reconnectRemoteConnection()` keeps working, but that meant `isRemoteWorkspace` alone -- - /// config presence, not live connection state -- excluded the whole workspace, and every - /// local pane in it, from `sessionSnapshot(includeScrollback:)` forever after disconnect. - @MainActor - func testDisconnectedRemoteWorkspacePersistsLocalPanelsInSessionSnapshot() throws { - let manager = TabManager() - let remoteWorkspace = manager.addWorkspace(select: true) - let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64034, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - // Configuring seeds the workspace's sole existing terminal panel as a tracked remote - // surface; two more splits created while still configured pick up the same tracking -- - // mirroring the incident's three live remote-tracked terminals. - remoteWorkspace.configureRemoteConnection(config, autoConnect: false) - let firstPanelId = try XCTUnwrap(remoteWorkspace.focusedTerminalPanel?.id) - let secondPanelId = try XCTUnwrap(remoteWorkspace.newTerminalSurface(inPane: paneId, focus: false)?.id) - let thirdPanelId = try XCTUnwrap(remoteWorkspace.newTerminalSurface(inPane: paneId, focus: false)?.id) - for panelId in [firstPanelId, secondPanelId, thirdPanelId] { - XCTAssertTrue(remoteWorkspace.isRemoteTerminalSurface(panelId)) - } - - remoteWorkspace.applyRemoteConnectionStateUpdate(.connected, detail: nil, target: "cmux-macmini") - XCTAssertEqual(remoteWorkspace.remoteConnectionState, .connected) - - // The user disconnects from the sidebar -- mirrors `TabItemView`'s disconnect action, - // which passes `clearConfiguration: false` so Reconnect keeps working. - remoteWorkspace.disconnectRemoteConnection(clearConfiguration: false) - XCTAssertTrue(remoteWorkspace.isRemoteWorkspace) - XCTAssertEqual(remoteWorkspace.remoteConnectionState, .disconnected) - for panelId in [firstPanelId, secondPanelId, thirdPanelId] { - XCTAssertFalse(remoteWorkspace.isRemoteTerminalSurface(panelId)) - } - - let snapshot = manager.sessionSnapshot(includeScrollback: false) - let restoredPanelIds = Set( - snapshot.workspaces - .first(where: { $0.panels.contains { $0.id == firstPanelId } })? - .panels - .map(\.id) ?? [] - ) - - XCTAssertTrue(restoredPanelIds.contains(firstPanelId)) - XCTAssertTrue(restoredPanelIds.contains(secondPanelId)) - XCTAssertTrue(restoredPanelIds.contains(thirdPanelId)) - } - - @MainActor - func testRemoteTerminalSessionEndRequestsControlMasterCleanupWhenWorkspaceDemotes() throws { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64012, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - workspace.configureRemoteConnection(config, autoConnect: false) - - let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) - workspace.markRemoteTerminalSessionEnded(surfaceId: panelID, relayPort: 64012) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertFalse(workspace.isRemoteWorkspace) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - XCTAssertEqual( - capturedArguments, - [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - "-p", "2222", - "-i", "/Users/test/.ssh/id_ed25519", - "-o", "ControlPath=/tmp/programa-ssh-%C", - "-o", "StrictHostKeyChecking=accept-new", - "-O", "exit", - "cmux-macmini", - ] - ) - } - - @MainActor - func testTeardownRemoteConnectionRequestsControlMasterCleanupWhileStillConnecting() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64014, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - workspace.configureRemoteConnection(config, autoConnect: false) - workspace.applyRemoteConnectionStateUpdate( - .connecting, - detail: "Connecting to cmux-macmini", - target: "cmux-macmini" - ) - - workspace.teardownRemoteConnection() - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertFalse(workspace.isRemoteWorkspace) - XCTAssertEqual( - capturedArguments, - [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - "-o", "ControlPath=/tmp/programa-ssh-%C", - "-O", "exit", - "cmux-macmini", - ] - ) - } - - @MainActor - func testTeardownRemoteConnectionRequestsControlMasterCleanupWithoutExplicitControlPath() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64015, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - workspace.configureRemoteConnection(config, autoConnect: false) - workspace.applyRemoteConnectionStateUpdate( - .connecting, - detail: "Connecting to cmux-macmini", - target: "cmux-macmini" - ) - - workspace.teardownRemoteConnection() - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertFalse(workspace.isRemoteWorkspace) - XCTAssertEqual( - capturedArguments, - [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - "-O", "exit", - "cmux-macmini", - ] - ) - } - - @MainActor - func testClosingRemoteWorkspaceRequestsControlMasterCleanup() throws { - let manager = TabManager() - let remainingWorkspace = try XCTUnwrap(manager.selectedWorkspace) - let remoteWorkspace = manager.addWorkspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64018, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - var capturedArguments: [String] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - capturedArguments = arguments - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - remoteWorkspace.configureRemoteConnection(config, autoConnect: false) - - manager.closeWorkspace(remoteWorkspace) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertEqual(manager.tabs.count, 1) - XCTAssertEqual(manager.tabs.first?.id, remainingWorkspace.id) - XCTAssertFalse(manager.tabs.contains(where: { $0.id == remoteWorkspace.id })) - XCTAssertFalse(remoteWorkspace.isRemoteWorkspace) - XCTAssertEqual( - capturedArguments, - [ - "-o", "BatchMode=yes", - "-o", "ControlMaster=no", - "-p", "2222", - "-i", "/Users/test/.ssh/id_ed25519", - "-o", "ControlPath=/tmp/programa-ssh-%C", - "-o", "StrictHostKeyChecking=accept-new", - "-O", "exit", - "cmux-macmini", - ] - ) - } - - @MainActor - func testDetachLastRemoteSurfacePreservesRemoteSessionWithoutCleanup() throws { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64016, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - cleanupRequested.isInverted = true - - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - workspace.configureRemoteConnection(config, autoConnect: false) - - let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let panelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) - let detached = try XCTUnwrap(workspace.detachSurface(panelId: panelID)) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertTrue(detached.isRemoteTerminal) - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - - let reattachedSurfaceID = workspace.attachDetachedSurface(detached, inPane: paneID, focus: false) - - XCTAssertNotNil(reattachedSurfaceID) - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) - XCTAssertTrue(workspace.isRemoteTerminalSurface(detached.panelId)) - } - - @MainActor - func testFailedDetachedRemoteResolutionFinalizesCleanupExactlyOnce() throws { - let source = Workspace() - let destination = Workspace() - defer { - source.teardownAllPanels() - destination.teardownAllPanels() - Workspace.runSSHControlMasterCommandOverrideForTesting = nil - } - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: ["ControlMaster=auto", "ControlPersist=600", "ControlPath=/tmp/programa-ssh-%C"], - localProxyPort: nil, - relayPort: 64026, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - source.configureRemoteConnection(config, autoConnect: false) - let panel = try XCTUnwrap(source.focusedTerminalPanel) - let transfer = try XCTUnwrap(source.detachSurface(panelId: panel.id)) - let cleanupRequested = expectation(description: "detached remote finalization cleanup") - var cleanupArguments: [[String]] = [] - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - cleanupArguments.append(arguments) - cleanupRequested.fulfill() - } - - let result = transfer.resolve( - primary: Workspace.DetachedSurfaceAttachmentTarget(workspace: destination, paneId: PaneID(), index: nil, focus: false), - rollback: Workspace.DetachedSurfaceAttachmentTarget(workspace: source, paneId: PaneID(), index: nil, focus: false) - ) - guard case .finalized = result else { - return XCTFail("Failed primary and rollback attachment must finalize the carried remote surface") - } - transfer.finalizePermanently() - transfer.finalizePermanently() - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertEqual(cleanupArguments.count, 1) - XCTAssertEqual(cleanupArguments.first?.suffix(2), ["exit", "cmux-macmini"]) - XCTAssertFalse(panel.surface.hasLiveSurface) - } - - @MainActor - func testSuccessfulDetachedRemoteResolutionDefersCleanupUntilPermanentDestinationClose() throws { - let source = Workspace() - let destination = Workspace() - defer { - source.teardownAllPanels() - destination.teardownAllPanels() - Workspace.runSSHControlMasterCommandOverrideForTesting = nil - } - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: ["ControlMaster=auto", "ControlPersist=600", "ControlPath=/tmp/programa-ssh-%C"], - localProxyPort: nil, - relayPort: 64027, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - source.configureRemoteConnection(config, autoConnect: false) - let panelID = try XCTUnwrap(source.focusedTerminalPanel?.id) - let transfer = try XCTUnwrap(source.detachSurface(panelId: panelID)) - let destinationPane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - let prematureCleanup = expectation(description: "no cleanup after successful transfer") - prematureCleanup.isInverted = true - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in prematureCleanup.fulfill() } - - let result = transfer.resolve( - primary: Workspace.DetachedSurfaceAttachmentTarget( - workspace: destination, - paneId: destinationPane, - index: nil, - focus: false - ), - rollback: nil - ) - guard case .attachedPrimary(let attachedPanelID) = result else { - return XCTFail("Expected destination attachment to succeed") - } - XCTAssertEqual(attachedPanelID, panelID) - wait(for: [prematureCleanup], timeout: 0.1) - - let cleanupRequested = expectation(description: "cleanup after permanent destination close") - var cleanupCount = 0 - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupCount += 1 - cleanupRequested.fulfill() - } - destination.teardownAllPanels() - wait(for: [cleanupRequested], timeout: 1.0) - destination.teardownAllPanels() - source.teardownAllPanels() - XCTAssertEqual(cleanupCount, 1) - } - - @MainActor - func testTransferredRemoteSurfaceWithMatchingPortButDifferentHostKeepsSourceCleanupOwnership() throws { - let source = Workspace() - let destination = Workspace() - defer { - Workspace.runSSHControlMasterCommandOverrideForTesting = nil - source.teardownAllPanels() - destination.teardownAllPanels() - } - let sharedRelayPort = 64028 - let sourceConfiguration = WorkspaceRemoteConfiguration( - destination: "host-a.example", - port: nil, - identityFile: nil, - sshOptions: ["ControlMaster=auto", "ControlPersist=600", "ControlPath=/tmp/programa-host-a-%C"], - localProxyPort: nil, - relayPort: sharedRelayPort, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "a", count: 64), - localSocketPath: "/tmp/programa-host-a.sock", - terminalStartupCommand: "ssh host-a.example" - ) - let destinationConfiguration = WorkspaceRemoteConfiguration( - destination: "host-b.example", - port: nil, - identityFile: nil, - sshOptions: ["ControlMaster=auto", "ControlPersist=600", "ControlPath=/tmp/programa-host-b-%C"], - localProxyPort: nil, - relayPort: sharedRelayPort, - relayID: String(repeating: "b", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-host-b.sock", - terminalStartupCommand: "ssh host-b.example" - ) - source.configureRemoteConnection(sourceConfiguration, autoConnect: false) - destination.configureRemoteConnection(destinationConfiguration, autoConnect: false) - let sourcePanelID = try XCTUnwrap(source.focusedTerminalPanel?.id) - let transfer = try XCTUnwrap(source.detachSurface(panelId: sourcePanelID)) - let destinationPane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - var cleanupArguments: [[String]] = [] - Workspace.runSSHControlMasterCommandOverrideForTesting = { cleanupArguments.append($0) } - - let result = transfer.resolve( - primary: Workspace.DetachedSurfaceAttachmentTarget( - workspace: destination, - paneId: destinationPane, - index: nil, - focus: false - ), - rollback: nil - ) - guard case .attachedPrimary(let attachedPanelID) = result else { - return XCTFail("The destination should accept the transferred terminal panel") - } - XCTAssertEqual(attachedPanelID, sourcePanelID) - XCTAssertFalse( - destination.isRemoteTerminalSurface(sourcePanelID), - "A matching relay port cannot make a host-A terminal part of host B's remote session" - ) - XCTAssertEqual(destination.activeRemoteTerminalSessionCount, 1) - - destination.teardownAllPanels() - destination.teardownAllPanels() - - XCTAssertEqual(cleanupArguments.count, 1) - XCTAssertEqual(cleanupArguments.first?.suffix(2), ["exit", "host-a.example"]) - } - - @MainActor - func testClosingSourceWorkspaceAfterDetachingRemoteSurfaceSkipsControlMasterCleanup() throws { - let manager = TabManager() - let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) - let destinationWorkspace = manager.addWorkspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64017, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - cleanupRequested.isInverted = true - - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - sourceWorkspace.configureRemoteConnection(config, autoConnect: false) - - let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) - let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) - let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) - - let restoredPanelID = destinationWorkspace.attachDetachedSurface( - detached, - inPane: destinationPaneID, - focus: false - ) - - XCTAssertNotNil(restoredPanelID) - XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) - XCTAssertTrue(sourceWorkspace.panels.isEmpty) - - manager.closeWorkspace(sourceWorkspace) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertFalse(manager.tabs.contains(where: { $0.id == sourceWorkspace.id })) - XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) - } - - @MainActor - func testClosingMixedSourceWorkspaceAfterDetachingLastRemoteSurfaceSkipsControlMasterCleanup() throws { - let manager = TabManager() - let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) - let destinationWorkspace = manager.addWorkspace() - let sourcePaneID = try XCTUnwrap(sourceWorkspace.bonsplitController.allPaneIds.first) - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64018, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - cleanupRequested.isInverted = true - - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - sourceWorkspace.configureRemoteConnection(config, autoConnect: false) - _ = sourceWorkspace.newBrowserSurface(inPane: sourcePaneID, url: URL(string: "https://example.com"), focus: false) - - let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) - let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) - let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) - - let restoredPanelID = destinationWorkspace.attachDetachedSurface( - detached, - inPane: destinationPaneID, - focus: false - ) - - XCTAssertNotNil(restoredPanelID) - XCTAssertEqual(sourceWorkspace.panels.count, 1) - XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) - - manager.closeWorkspace(sourceWorkspace) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertFalse(manager.tabs.contains(where: { $0.id == sourceWorkspace.id })) - XCTAssertTrue(destinationWorkspace.panels.keys.contains(detached.panelId)) - } - - @MainActor - func testTransferredRemoteSurfaceCleansUpControlMasterWhenSessionEndsInLocalWorkspace() throws { - let manager = TabManager() - let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) - let destinationWorkspace = manager.addWorkspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64019, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - var cleanupArguments: [[String]] = [] - - Workspace.runSSHControlMasterCommandOverrideForTesting = { arguments in - cleanupArguments.append(arguments) - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - sourceWorkspace.configureRemoteConnection(config, autoConnect: false) - - let panelID = try XCTUnwrap(sourceWorkspace.focusedTerminalPanel?.id) - let detached = try XCTUnwrap(sourceWorkspace.detachSurface(panelId: panelID)) - let destinationPaneID = try XCTUnwrap(destinationWorkspace.bonsplitController.allPaneIds.first) - - let restoredPanelID = destinationWorkspace.attachDetachedSurface( - detached, - inPane: destinationPaneID, - focus: false - ) - - XCTAssertNotNil(restoredPanelID) - XCTAssertFalse(destinationWorkspace.isRemoteWorkspace) - XCTAssertEqual(destinationWorkspace.activeRemoteTerminalSessionCount, 0) - - manager.closeWorkspace(sourceWorkspace) - destinationWorkspace.markRemoteTerminalSessionEnded(surfaceId: detached.panelId, relayPort: config.relayPort) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertEqual(cleanupArguments.count, 1) - XCTAssertEqual(cleanupArguments.first?.suffix(2), ["exit", "cmux-macmini"]) - } - - @MainActor - func testRemoteTerminalSessionEndSkipsControlMasterCleanupWhenBrowserPanelsKeepWorkspaceRemote() throws { - let workspace = Workspace() - let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) - let initialTerminalID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - ], - localProxyPort: nil, - relayPort: 64013, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - let cleanupRequested = expectation(description: "control master cleanup requested") - cleanupRequested.isInverted = true - - Workspace.runSSHControlMasterCommandOverrideForTesting = { _ in - cleanupRequested.fulfill() - } - defer { Workspace.runSSHControlMasterCommandOverrideForTesting = nil } - - workspace.configureRemoteConnection(config, autoConnect: false) - _ = workspace.newBrowserSurface(inPane: paneID, url: URL(string: "https://example.com"), focus: false) - - workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalID, relayPort: 64013) - - wait(for: [cleanupRequested], timeout: 1.0) - - XCTAssertTrue(workspace.isRemoteWorkspace) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) - } - - func testRemoteDropPathUsesLowercasedExtensionAndProvidedUUID() throws { - let fileURL = URL(fileURLWithPath: "/Users/test/Screen Shot.PNG") - let uuid = try XCTUnwrap(UUID(uuidString: "12345678-1234-1234-1234-1234567890AB")) - - let remotePath = WorkspaceRemoteSessionController.remoteDropPath(for: fileURL, uuid: uuid) - - XCTAssertEqual(remotePath, "/tmp/programa-drop-12345678-1234-1234-1234-1234567890ab.png") - } - - @MainActor - func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64007, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - workspace.configureRemoteConnection(config, autoConnect: false) - - let originalPanelID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) - let originalPaneID = try XCTUnwrap(workspace.paneId(forPanelId: originalPanelID)) - let movedPanel = try XCTUnwrap( - workspace.newTerminalSplit(from: originalPanelID, orientation: .horizontal) - ) - - XCTAssertTrue(workspace.isRemoteTerminalSurface(originalPanelID)) - XCTAssertTrue(workspace.isRemoteTerminalSurface(movedPanel.id)) - - let detached = try XCTUnwrap(workspace.detachSurface(panelId: movedPanel.id)) - XCTAssertTrue(detached.isRemoteTerminal) - XCTAssertEqual(detached.remoteRelayPort, config.relayPort) - - let restoredPanelID = workspace.attachDetachedSurface( - detached, - inPane: originalPaneID, - focus: false - ) - - XCTAssertEqual(restoredPanelID, movedPanel.id) - XCTAssertTrue(workspace.isRemoteTerminalSurface(movedPanel.id)) - } - - @MainActor - func testDetachAttachPreservesSurfaceTTYMetadata() throws { - let source = Workspace() - let destination = Workspace() - - let panelID = try XCTUnwrap(source.focusedTerminalPanel?.id) - let sourcePaneID = try XCTUnwrap(source.paneId(forPanelId: panelID)) - let destinationPaneID = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) - source.surfaceTTYNames[panelID] = "/dev/ttys004" - - let detached = try XCTUnwrap(source.detachSurface(panelId: panelID)) - XCTAssertEqual(source.surfaceTTYNames[panelID], nil) - - let restoredPanelID = destination.attachDetachedSurface( - detached, - inPane: destinationPaneID, - focus: false - ) - - XCTAssertEqual(restoredPanelID, panelID) - XCTAssertEqual(destination.surfaceTTYNames[panelID], "/dev/ttys004") - XCTAssertEqual(source.bonsplitController.tabs(inPane: sourcePaneID).count, 0) - } - - func testDetectedSSHUploadFailureCleansUpEarlierRemoteUploads() throws { - let fileManager = FileManager.default - let directoryURL = fileManager.temporaryDirectory.appendingPathComponent( - "cmux-detected-ssh-upload-\(UUID().uuidString)", - isDirectory: true - ) - try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: directoryURL) } - - let firstFileURL = directoryURL.appendingPathComponent("first.png") - let secondFileURL = directoryURL.appendingPathComponent("second.png") - try Data("first".utf8).write(to: firstFileURL) - try Data("second".utf8).write(to: secondFileURL) - - let session = DetectedSSHSession( - destination: "lawrence@example.com", - port: 2200, - identityFile: "/Users/test/.ssh/id_ed25519", - configFile: nil, - jumpHost: nil, - controlPath: nil, - useIPv4: false, - useIPv6: false, - forwardAgent: false, - compressionEnabled: false, - sshOptions: [] - ) - - var invocations: [(executable: String, arguments: [String])] = [] - var scpInvocationCount = 0 - DetectedSSHSession.runProcessOverrideForTesting = { executable, arguments, _, _ in - invocations.append((executable, arguments)) - if executable == "/usr/bin/scp" { - scpInvocationCount += 1 - if scpInvocationCount == 1 { - return (status: 0, stdout: "", stderr: "") - } - return (status: 1, stdout: "", stderr: "copy failed") - } - if executable == "/usr/bin/ssh" { - return (status: 0, stdout: "", stderr: "") - } - XCTFail("unexpected executable \(executable)") - return (status: 1, stdout: "", stderr: "unexpected executable") - } - defer { DetectedSSHSession.runProcessOverrideForTesting = nil } - - XCTAssertThrowsError( - try session.uploadDroppedFilesSyncForTesting([firstFileURL, secondFileURL]) - ) - - let firstSCPDestination = try XCTUnwrap( - invocations - .first(where: { $0.executable == "/usr/bin/scp" })? - .arguments - .last - ) - let uploadedRemotePath = try XCTUnwrap(firstSCPDestination.split(separator: ":", maxSplits: 1).last) - let cleanupInvocation = try XCTUnwrap( - invocations.first(where: { $0.executable == "/usr/bin/ssh" }) - ) - let cleanupCommand = cleanupInvocation.arguments.joined(separator: " ") - - XCTAssertTrue(cleanupCommand.contains(String(uploadedRemotePath))) - } - - func testDetectsForegroundSSHSessionForTTY() { - let session = TerminalSSHSessionDetector.detectForTesting( - ttyName: "/dev/ttys004", - processes: [ - .init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"), - ], - argumentsByPID: [ - 2145: [ - "ssh", - "-o", "ControlMaster=auto", - "-o", "ControlPath=/tmp/programa-ssh-%C", - "-o", "StrictHostKeyChecking=accept-new", - "-p", "2200", - "-i", "/Users/test/.ssh/id_ed25519", - "lawrence@example.com", - ], - ] - ) - - XCTAssertEqual( - session, - DetectedSSHSession( - destination: "lawrence@example.com", - port: 2200, - identityFile: "/Users/test/.ssh/id_ed25519", - configFile: nil, - jumpHost: nil, - controlPath: "/tmp/programa-ssh-%C", - useIPv4: false, - useIPv6: false, - forwardAgent: false, - compressionEnabled: false, - sshOptions: [ - "StrictHostKeyChecking=accept-new", - ] - ) - ) - } - - func testDetectsForegroundSSHSessionWithShortControlPathFlag() { - let session = TerminalSSHSessionDetector.detectForTesting( - ttyName: "/dev/ttys004", - processes: [ - .init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"), - ], - argumentsByPID: [ - 2145: [ - "ssh", - "-S", "/tmp/programa-ssh-%C", - "-p", "2200", - "lawrence@example.com", - ], - ] - ) - - XCTAssertEqual(session?.controlPath, "/tmp/programa-ssh-%C") - let scpArgs = session?.scpArgumentsForTesting( - localPath: "/tmp/local.png", - remotePath: "/tmp/programa-drop-123.png" - ) ?? [] - XCTAssertTrue(scpArgs.contains("ControlPath=/tmp/programa-ssh-%C")) - XCTAssertFalse(scpArgs.contains("-S")) - } - - func testDaemonTransportArgumentsReuseConfiguredControlPath() { - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: "ssh cmux-macmini" - ) - - let arguments = WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( - configuration: configuration, - remotePath: "/remote/programad-remote" - ) - - XCTAssertFalse(arguments.contains("-S")) - XCTAssertTrue(arguments.contains("ControlMaster=no")) - XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/programa-ssh-%C" || $0 == "ControlPath=/tmp/programa-ssh-%C" })) - XCTAssertTrue(arguments.contains("cmux-macmini")) - XCTAssertTrue(arguments.last?.contains("/remote/programad-remote") ?? false) - } - - func testDaemonTransportArgumentsReuseWhitespaceConfiguredControlPath() { - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster auto", - "ControlPersist 600", - "ControlPath /tmp/programa-ssh-%C", - "StrictHostKeyChecking accept-new", - ], - localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: "ssh cmux-macmini" - ) - - let arguments = WorkspaceRemoteSSHBatchCommandBuilder.daemonTransportArguments( - configuration: configuration, - remotePath: "/remote/programad-remote" - ) - - XCTAssertFalse(arguments.contains("-S")) - XCTAssertTrue(arguments.contains("ControlMaster=no")) - XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/programa-ssh-%C" || $0 == "ControlPath=/tmp/programa-ssh-%C" })) - } - - func testReverseRelayControlMasterArgumentsReuseConfiguredControlSocket() throws { - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "ControlPath=/tmp/programa-ssh-%C", - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: 64007, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: "ssh cmux-macmini" - ) - - let arguments = try XCTUnwrap( - WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "forward", - forwardSpec: "127.0.0.1:64007:127.0.0.1:54321" - ) - ) - - XCTAssertFalse(arguments.contains("-S")) - XCTAssertTrue(arguments.contains("ControlMaster=no")) - XCTAssertTrue(arguments.contains("ControlPath=/tmp/programa-ssh-%C")) - XCTAssertTrue(arguments.contains("-O")) - XCTAssertTrue(arguments.contains("forward")) - XCTAssertTrue(arguments.contains("-R")) - XCTAssertTrue(arguments.contains("127.0.0.1:64007:127.0.0.1:54321")) - XCTAssertTrue(arguments.contains("cmux-macmini")) - } - - func testReverseRelayControlMasterArgumentsReuseWhitespaceConfiguredControlSocket() throws { - let configuration = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster auto", - "ControlPersist 600", - "ControlPath /tmp/programa-ssh-%C", - "StrictHostKeyChecking accept-new", - ], - localProxyPort: nil, - relayPort: 64033, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: "ssh cmux-macmini" - ) - - let arguments = try XCTUnwrap( - WorkspaceRemoteSSHBatchCommandBuilder.reverseRelayControlMasterArguments( - configuration: configuration, - controlCommand: "forward", - forwardSpec: "127.0.0.1:64033:127.0.0.1:54321" - ) - ) - - XCTAssertFalse(arguments.contains("-S")) - XCTAssertTrue(arguments.contains("ControlMaster=no")) - XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/programa-ssh-%C" || $0 == "ControlPath=/tmp/programa-ssh-%C" })) - XCTAssertTrue(arguments.contains("-O")) - XCTAssertTrue(arguments.contains("forward")) - } - - func testDetectedSSHSessionBracketsIPv6LiteralSCPDestination() { - let session = DetectedSSHSession( - destination: "lawrence@2001:db8::1", - port: nil, - identityFile: nil, - configFile: nil, - jumpHost: nil, - controlPath: nil, - useIPv4: false, - useIPv6: false, - forwardAgent: false, - compressionEnabled: false, - sshOptions: [] - ) - - let scpArgs = session.scpArgumentsForTesting( - localPath: "/tmp/local.png", - remotePath: "/tmp/programa-drop-123.png" - ) - - XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8::1]:/tmp/programa-drop-123.png") - } - - func testRemoteSSHConnectionPolicyScpRemoteDestinationBracketsBareIPv6Literal() { - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@2001:db8::1"), - "lawrence@[2001:db8::1]" - ) - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("2001:db8::1"), - "[2001:db8::1]" - ) - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("::1"), - "[::1]" - ) - } - - func testRemoteSSHConnectionPolicyScpRemoteDestinationPassesThroughNonBareIPv6Hosts() { - // Already-bracketed hosts are left alone. - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@[2001:db8::1]"), - "lawrence@[2001:db8::1]" - ) - // IPv4 literals have no ambiguous colon, so they pass through untouched. - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@192.168.1.1"), - "lawrence@192.168.1.1" - ) - // Plain hostnames and configured SSH aliases pass through untouched. - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("cmux-macmini"), - "cmux-macmini" - ) - XCTAssertEqual( - RemoteSSHConnectionPolicy.scpRemoteDestination("lawrence@example.com"), - "lawrence@example.com" - ) - } - - func testScpUploadArgumentsBracketsIPv6LiteralDestinationForDaemonBinaryUpload() { - let configuration = WorkspaceRemoteConfiguration( - destination: "2001:db8::1", - port: 2222, - identityFile: "/Users/test/.ssh/id_ed25519", - sshOptions: [ - "ControlMaster=auto", - "ControlPersist=600", - "StrictHostKeyChecking=accept-new", - ], - localProxyPort: nil, - relayPort: nil, - relayID: nil, - relayToken: nil, - localSocketPath: nil, - terminalStartupCommand: "ssh [2001:db8::1]" - ) - - // This is the exact call `WorkspaceRemoteSessionController+DaemonInstall.swift` - // makes for both the programad-remote binary upload and dropped-file uploads - // (#4948 follow-up: the ssh-only fix in CLI+SSH.swift left these scp call - // sites choking on bracketless IPv6 hosts). - let arguments = WorkspaceRemoteSSHBatchCommandBuilder.scpUploadArguments( - configuration: configuration, - localPath: "/tmp/programad-remote", - remotePath: "/home/lawrence/.programa/remote/programad-remote" - ) - - XCTAssertEqual( - arguments.last, - "[2001:db8::1]:/home/lawrence/.programa/remote/programad-remote" - ) - XCTAssertTrue(arguments.contains("-P")) - XCTAssertTrue(arguments.contains("2222")) - XCTAssertTrue(arguments.contains("-i")) - XCTAssertTrue(arguments.contains("/Users/test/.ssh/id_ed25519")) - } - - func testDetectsForegroundSSHSessionWithLowercaseAgentFlag() { - let session = TerminalSSHSessionDetector.detectForTesting( - ttyName: "/dev/ttys004", - processes: [ - .init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"), - ], - argumentsByPID: [ - 2145: [ - "ssh", - "-a", - "lawrence@example.com", - ], - ] - ) - - XCTAssertEqual(session?.destination, "lawrence@example.com") - XCTAssertFalse(session?.forwardAgent ?? true) - } - - func testDetectsForegroundSSHSessionIgnoringBindInterfaceValue() { - let session = TerminalSSHSessionDetector.detectForTesting( - ttyName: "/dev/ttys004", - processes: [ - .init(pid: 2145, pgid: 1967, tpgid: 1967, tty: "ttys004", executableName: "ssh"), - ], - argumentsByPID: [ - 2145: [ - "ssh", - "-B", "en0", - "lawrence@example.com", - ], - ] - ) - - XCTAssertEqual(session?.destination, "lawrence@example.com") - } - - func testIgnoresBackgroundSSHProcessForTTY() { - let session = TerminalSSHSessionDetector.detectForTesting( - ttyName: "ttys004", - processes: [ - .init(pid: 2145, pgid: 2145, tpgid: 1967, tty: "ttys004", executableName: "ssh"), - ], - argumentsByPID: [ - 2145: ["ssh", "lawrence@example.com"], - ] - ) - - XCTAssertNil(session) - } - - @MainActor - func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() { - let workspace = Workspace() - let config = WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64007, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ) - - workspace.configureRemoteConnection(config, autoConnect: false) - XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) - - let proxyError = "Remote proxy to cmux-macmini unavailable: Failed to start local daemon proxy: daemon RPC timeout waiting for hello response (retry in 3s)" - workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "cmux-macmini") - - XCTAssertEqual(workspace.remoteConnectionState, .connected) - XCTAssertEqual(workspace.remoteConnectionDetail, proxyError) - XCTAssertEqual( - workspace.statusEntries["remote.error"]?.value, - "Remote proxy unavailable (cmux-macmini): \(proxyError)" - ) - XCTAssertEqual(workspace.logEntries.last?.source, "remote-proxy") - XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true) - XCTAssertEqual( - ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), - "error" - ) - - workspace.applyRemoteConnectionStateUpdate(.connecting, detail: "Connecting to cmux-macmini", target: "cmux-macmini") - - XCTAssertEqual(workspace.remoteConnectionState, .connected) - XCTAssertEqual( - workspace.statusEntries["remote.error"]?.value, - "Remote proxy unavailable (cmux-macmini): \(proxyError)" - ) - - workspace.applyRemoteConnectionStateUpdate( - .connected, - detail: "Connected to cmux-macmini via shared local proxy 127.0.0.1:9999", - target: "cmux-macmini" - ) - - XCTAssertEqual(workspace.remoteConnectionState, .connected) - XCTAssertNil(workspace.statusEntries["remote.error"]) - XCTAssertEqual( - ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), - "unavailable" - ) - } -} - -final class CLINotifyProcessIntegrationTests: XCTestCase { - private struct ProcessRunResult { - let status: Int32 - let stdout: String - let stderr: String - let timedOut: Bool - } - - private final class MockSocketServerState: @unchecked Sendable { - private let lock = NSLock() - private(set) var commands: [String] = [] - - func append(_ command: String) { - lock.lock() - commands.append(command) - lock.unlock() - } - } - - private func makeSocketPath(_ name: String) -> String { - let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) - return URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock") - .path - } - - private func bundledCLIPath() throws -> String { - let fileManager = FileManager.default - let appBundleURL = Bundle(for: Self.self) - .bundleURL - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let enumerator = fileManager.enumerator( - at: appBundleURL, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) - - while let item = enumerator?.nextObject() as? URL { - guard item.lastPathComponent == "programa", - item.path.contains(".app/Contents/Resources/bin/programa") else { - continue - } - return item.path - } - - throw XCTSkip("Bundled cmux CLI not found in \(appBundleURL.path)") - } - - private func runProcess( - executablePath: String, - arguments: [String], - environment: [String: String], - timeout: TimeInterval - ) -> ProcessRunResult { - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = arguments - process.environment = environment - process.standardInput = FileHandle.nullDevice - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - // See the sibling `runProcess` above for why this uses `terminationHandler` instead of - // parking a pooled thread in `waitUntilExit()`. - let exitSignal = DispatchSemaphore(value: 0) - process.terminationHandler = { _ in exitSignal.signal() } - - do { - try process.run() - } catch { - return ProcessRunResult( - status: -1, - stdout: "", - stderr: String(describing: error), - timedOut: false - ) - } - - let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut - if timedOut { - process.terminate() - _ = exitSignal.wait(timeout: .now() + 1) - } - - let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - return ProcessRunResult( - status: process.terminationStatus, - stdout: stdout, - stderr: stderr, - timedOut: timedOut - ) - } - - private func bindUnixSocket(at path: String) throws -> Int32 { - unlink(path) - - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw NSError( - domain: NSPOSIXErrorDomain, - code: Int(errno), - userInfo: [NSLocalizedDescriptionKey: "Failed to create Unix socket"] - ) - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strncpy(pathBuf, ptr, maxPathLength - 1) - } - } - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) - } - } - guard bindResult == 0 else { - let code = Int(errno) - Darwin.close(fd) - throw NSError( - domain: NSPOSIXErrorDomain, - code: code, - userInfo: [NSLocalizedDescriptionKey: "Failed to bind Unix socket"] - ) - } - - guard Darwin.listen(fd, 1) == 0 else { - let code = Int(errno) - Darwin.close(fd) - throw NSError( - domain: NSPOSIXErrorDomain, - code: code, - userInfo: [NSLocalizedDescriptionKey: "Failed to listen on Unix socket"] - ) - } - - return fd - } - - private func startMockServer( - listenerFD: Int32, - state: MockSocketServerState, - handler: @escaping @Sendable (String) -> String - ) -> XCTestExpectation { - let handled = expectation(description: "cli mock socket handled") - DispatchQueue.global(qos: .userInitiated).async { - var clientAddr = sockaddr_un() - var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size) - let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) - } - } - guard clientFD >= 0 else { - handled.fulfill() - return - } - defer { - Darwin.close(clientFD) - handled.fulfill() - } - - var pending = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - - while true { - let count = Darwin.read(clientFD, &buffer, buffer.count) - if count < 0 { - if errno == EINTR { continue } - return - } - if count == 0 { return } - pending.append(buffer, count: count) - - while let newlineRange = pending.firstRange(of: Data([0x0A])) { - let lineData = pending.subdata(in: 0..<newlineRange.lowerBound) - pending.removeSubrange(0...newlineRange.lowerBound) - guard let line = String(data: lineData, encoding: .utf8) else { continue } - state.append(line) - let response = handler(line) + "\n" - _ = response.withCString { ptr in - Darwin.write(clientFD, ptr, strlen(ptr)) - } - } - } - } - return handled - } - - private func v2Response( - id: String, - ok: Bool, - result: [String: Any]? = nil, - error: [String: Any]? = nil - ) -> String { - var payload: [String: Any] = ["id": id, "ok": ok] - if let result { - payload["result"] = result - } - if let error { - payload["error"] = error - } - let data = try? JSONSerialization.data(withJSONObject: payload, options: []) - return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" - } - - /// Polls `condition` until it returns true or `timeout` elapses. Same house pattern - /// as the `waitUntil` helpers in TerminalAndGhosttyTests.swift / - /// TerminalControllerSocketSecurityTests.swift: an `XCTNSPredicateExpectation` - /// driven by `XCTWaiter`, so a slow-but-eventual condition (e.g. a background - /// thread's file write finishing under CI scheduling contention) produces a clean, - /// bounded pass instead of a flaky immediate check. - @discardableResult - private func waitUntil( - timeout: TimeInterval = 5.0, - description: String, - _ condition: @escaping () -> Bool - ) -> Bool { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate { _, _ in condition() }, - object: NSObject() - ) - return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed - } - - @MainActor - func testNotifyFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("notify") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let currentWorkspace = "11111111-1111-1111-1111-111111111111" - let currentSurface = "22222222-2222-2222-2222-222222222222" - let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" - let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - if let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String { - let params = payload["params"] as? [String: Any] ?? [:] - switch method { - case "surface.list": - let workspaceId = params["workspace_id"] as? String - if workspaceId == staleWorkspace { - return self.v2Response( - id: id, - ok: false, - error: ["code": "not_found", "message": "Workspace not found"] - ) - } - if workspaceId == currentWorkspace { - return self.v2Response( - id: id, - ok: true, - result: [ - "surfaces": [ - [ - "id": currentSurface, - "ref": "surface:1", - "index": 0, - "focused": true - ] - ] - ] - ) - } - case "workspace.current": - return self.v2Response( - id: id, - ok: true, - result: ["workspace_id": currentWorkspace] - ) - case "notification.create_for_target": - return self.v2Response(id: id, ok: true, result: [:]) - default: - break - } - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - - if line == "notify_target \(currentWorkspace) \(currentSurface) Notification||" { - return "OK" - } - return "ERROR: Unexpected command \(line)" - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_WORKSPACE_ID"] = staleWorkspace - environment["PROGRAMA_SURFACE_ID"] = staleSurface - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["notify"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - let notifyRequests = state.commands.compactMap { line -> [String: Any]? in - guard let data = line.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } - XCTAssertTrue( - notifyRequests.contains { request in - guard request["method"] as? String == "notification.create_for_target" else { return false } - let params = request["params"] as? [String: Any] ?? [:] - return params["workspace_id"] as? String == currentWorkspace - && params["surface_id"] as? String == currentSurface - }, - "Expected notification.create_for_target to target current workspace and surface, saw \(state.commands)" - ) - } - - @MainActor - func testTriggerFlashFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("flash") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let currentWorkspace = "11111111-1111-1111-1111-111111111111" - let currentSurface = "22222222-2222-2222-2222-222222222222" - let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" - let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - let params = payload["params"] as? [String: Any] ?? [:] - switch method { - case "surface.list": - let workspaceId = params["workspace_id"] as? String - if workspaceId == staleWorkspace { - return self.v2Response( - id: id, - ok: false, - error: ["code": "not_found", "message": "Workspace not found"] - ) - } - if workspaceId == currentWorkspace { - return self.v2Response( - id: id, - ok: true, - result: [ - "surfaces": [ - [ - "id": currentSurface, - "ref": "surface:1", - "index": 0, - "focused": true - ] - ] - ] - ) - } - case "workspace.current": - return self.v2Response( - id: id, - ok: true, - result: ["workspace_id": currentWorkspace] - ) - case "surface.trigger_flash": - let workspaceId = params["workspace_id"] as? String - let surfaceId = params["surface_id"] as? String - if workspaceId == currentWorkspace, surfaceId == currentSurface { - return self.v2Response(id: id, ok: true, result: [:]) - } - default: - break - } - - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_WORKSPACE_ID"] = staleWorkspace - environment["PROGRAMA_SURFACE_ID"] = staleSurface - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["trigger-flash"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertTrue( - state.commands.contains { command in - guard let data = command.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let method = payload["method"] as? String, - method == "surface.trigger_flash" else { - return false - } - let params = payload["params"] as? [String: Any] ?? [:] - return (params["workspace_id"] as? String) == currentWorkspace - && (params["surface_id"] as? String) == currentSurface - }, - "Expected surface.trigger_flash to use current workspace and surface, saw \(state.commands)" - ) - } - - @MainActor - func testSSHCommandCreatesConfiguresAndSelectsRemoteWorkspaceViaCLI() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("ssh") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let workspaceID = "11111111-1111-1111-1111-111111111111" - let workspaceRef = "workspace:7" - let windowID = "22222222-2222-2222-2222-222222222222" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - switch method { - case "workspace.create": - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - "window_id": windowID, - ] - ) - case "workspace.rename": - return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) - case "workspace.remote.configure": - let params = payload["params"] as? [String: Any] ?? [:] - let autoConnect = (params["auto_connect"] as? Bool) ?? true - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - "workspace_ref": workspaceRef, - "remote": [ - "enabled": true, - "state": autoConnect ? "connecting" : "disconnected", - ], - ] - ) - case "workspace.select": - return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) - default: - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: [ - "ssh", - "--name", "SSH Workspace", - "--port", "2222", - "--identity", "/Users/test/.ssh/id_ed25519", - "--ssh-option", "ControlPath /tmp/programa-ssh-%C", - "--ssh-option", "StrictHostKeyChecking=accept-new", - "cmux-macmini", - ], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=disconnected\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - - let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) - } - XCTAssertEqual( - requests.compactMap { $0["method"] as? String }, - ["workspace.create", "workspace.rename", "workspace.remote.configure", "workspace.select"] - ) - - let createParams = try XCTUnwrap(requests[0]["params"] as? [String: Any]) - let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String) - XCTAssertFalse(initialCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - XCTAssertEqual(createParams["apply_remembered_folder_color"] as? Bool, false) - - let renameParams = try XCTUnwrap(requests[1]["params"] as? [String: Any]) - XCTAssertEqual(renameParams["workspace_id"] as? String, workspaceID) - XCTAssertEqual(renameParams["title"] as? String, "SSH Workspace") - - let configureParams = try XCTUnwrap(requests[2]["params"] as? [String: Any]) - XCTAssertEqual(configureParams["workspace_id"] as? String, workspaceID) - XCTAssertEqual(configureParams["destination"] as? String, "cmux-macmini") - XCTAssertEqual(configureParams["port"] as? Int, 2222) - XCTAssertEqual(configureParams["identity_file"] as? String, "/Users/test/.ssh/id_ed25519") - XCTAssertEqual(configureParams["local_socket_path"] as? String, socketPath) - XCTAssertEqual(configureParams["auto_connect"] as? Bool, false) - let relayPort = try XCTUnwrap(configureParams["relay_port"] as? Int) - XCTAssertGreaterThan(relayPort, 0) - let relayID = try XCTUnwrap(configureParams["relay_id"] as? String) - XCTAssertFalse(relayID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - let relayToken = try XCTUnwrap(configureParams["relay_token"] as? String) - XCTAssertEqual(relayToken.count, 64) - let foregroundAuthToken = try XCTUnwrap(configureParams["foreground_auth_token"] as? String) - XCTAssertFalse(foregroundAuthToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - let terminalStartupCommand = try XCTUnwrap(configureParams["terminal_startup_command"] as? String) - XCTAssertFalse(terminalStartupCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - XCTAssertTrue(sshOptions.contains("ControlMaster=auto")) - XCTAssertTrue(sshOptions.contains("ControlPersist=600")) - XCTAssertTrue(sshOptions.contains("ControlPath /tmp/programa-ssh-%C")) - XCTAssertTrue(sshOptions.contains("StrictHostKeyChecking=accept-new")) - - // `cmux ssh` should land the user in the new SSH workspace immediately. - let selectParams = try XCTUnwrap(requests[3]["params"] as? [String: Any]) - XCTAssertEqual(selectParams["workspace_id"] as? String, workspaceID) - XCTAssertEqual(selectParams["window_id"] as? String, windowID) - } - - @MainActor - func testSSHCommandDoesNotDeferReconnectWhenWhitespaceControlMasterDisablesMultiplexing() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("ssh-controlmaster-no") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let workspaceID = "11111111-1111-1111-1111-111111111111" - let workspaceRef = "workspace:9" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - switch method { - case "workspace.create": - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - ] - ) - case "workspace.remote.configure": - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - "workspace_ref": workspaceRef, - "remote": [ - "enabled": true, - "state": "connecting", - ], - ] - ) - default: - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: [ - "ssh", - "--no-focus", - "--port", "2222", - "--ssh-option", "ControlMaster no", - "--ssh-option", "ControlPath /tmp/programa-ssh-%C", - "cmux-macmini", - ], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - - let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) - } - XCTAssertEqual( - requests.compactMap { $0["method"] as? String }, - ["workspace.create", "workspace.remote.configure"] - ) - - let configureParams = try XCTUnwrap(requests[1]["params"] as? [String: Any]) - XCTAssertEqual(configureParams["auto_connect"] as? Bool, true) - XCTAssertNil(configureParams["foreground_auth_token"]) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - XCTAssertTrue(sshOptions.contains("ControlMaster no")) - XCTAssertTrue(sshOptions.contains("ControlPath /tmp/programa-ssh-%C")) - } - - @MainActor - func testSSHBootstrapStartupCommandPassesRemoteInstallScriptAsSingleSSHCommand() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("sshboot") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let workspaceID = "11111111-1111-1111-1111-111111111111" - let workspaceRef = "workspace:8" - let windowID = "22222222-2222-2222-2222-222222222222" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - switch method { - case "workspace.create": - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - "window_id": windowID, - ] - ) - case "workspace.rename": - return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) - case "workspace.remote.configure": - let params = payload["params"] as? [String: Any] ?? [:] - let autoConnect = (params["auto_connect"] as? Bool) ?? true - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - "workspace_ref": workspaceRef, - "remote": [ - "enabled": true, - "state": autoConnect ? "connecting" : "disconnected", - ], - ] - ) - case "workspace.select": - return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceID]) - default: - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: [ - "ssh", - "--name", "SSH Workspace", - "--port", "2222", - "--identity", "/Users/test/.ssh/id_ed25519", - "--ssh-option", "ControlPath=/tmp/programa-ssh-%C", - "--ssh-option", "StrictHostKeyChecking=accept-new", - "cmux-macmini", - ], - environment: environment, - // See `ciScale` (TabManagerUnitTests.swift): scale this subprocess/mock-socket - // round trip under CI, where it can legitimately take longer than on a fast - // local machine. - timeout: 5 * ciScale - ) - - wait(for: [serverHandled], timeout: 5 * ciScale) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - - let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) - } - let createParams = try XCTUnwrap(requests.first?["params"] as? [String: Any]) - let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String) - let configureParams = try XCTUnwrap(requests.dropFirst(2).first?["params"] as? [String: Any]) - let foregroundAuthToken = try XCTUnwrap(configureParams["foreground_auth_token"] as? String) - - let fileManager = FileManager.default - let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("cmux-ssh-bootstrap-\(UUID().uuidString)") - let fakeBin = tempRoot.appendingPathComponent("bin") - let fakeSSHLog = tempRoot.appendingPathComponent("fake-ssh.jsonl") - let fakeSSH = fakeBin.appendingPathComponent("ssh") - - try fileManager.createDirectory(at: fakeBin, withIntermediateDirectories: true) - defer { try? fileManager.removeItem(at: tempRoot) } - - let fakeSSHScript = """ - #!/bin/sh - python3 - "$@" <<'PY' - import json - import os - import subprocess - import sys - - args = sys.argv[1:] - with open(os.environ["PROGRAMA_FAKE_SSH_LOG"], "a", encoding="utf-8") as handle: - handle.write(json.dumps(args) + "\\n") - - local_command = None - for index, arg in enumerate(args): - if arg == "-o" and index + 1 < len(args) and args[index + 1].startswith("LocalCommand="): - local_command = args[index + 1].split("=", 1)[1] - break - - if local_command: - subprocess.run(["/bin/sh", "-c", local_command], check=False, env=os.environ.copy()) - PY - cat >/dev/null - exit 0 - """ - try fakeSSHScript.write(to: fakeSSH, atomically: true, encoding: .utf8) - try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fakeSSH.path) - - var startupEnvironment = ProcessInfo.processInfo.environment - startupEnvironment["HOME"] = tempRoot.path - startupEnvironment["PATH"] = "\(fakeBin.path):/usr/bin:/bin:/usr/sbin:/sbin" - startupEnvironment["PROGRAMA_FAKE_SSH_LOG"] = fakeSSHLog.path - startupEnvironment["PROGRAMA_SOCKET_PATH"] = socketPath - startupEnvironment["PROGRAMA_WORKSPACE_ID"] = workspaceID - startupEnvironment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - startupEnvironment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let foregroundAuthState = MockSocketServerState() - let foregroundAuthHandled = startMockServer(listenerFD: listenerFD, state: foregroundAuthState) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String, - method == "workspace.remote.foreground_auth_ready" else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - return self.v2Response( - id: id, - ok: true, - result: [ - "workspace_id": workspaceID, - "workspace_ref": workspaceRef, - "remote": [ - "enabled": true, - "state": "connecting", - ], - ] - ) - } - - let startupResult = runProcess( - executablePath: "/bin/sh", - arguments: ["-c", initialCommand], - environment: startupEnvironment, - timeout: 5 * ciScale - ) - - // The accept()-loop thread that fulfills `foregroundAuthHandled` (see - // startMockServer) only observes EOF and calls fulfill() once GCD schedules it — - // under a full serial suite run with heavy CPU contention that can legitimately - // take longer than the subprocess round-trip itself. Give it CI headroom rather - // than a hair-trigger local timeout. See `ciScale` (TabManagerUnitTests.swift). - wait(for: [foregroundAuthHandled], timeout: 15 * ciScale) - XCTAssertFalse(startupResult.timedOut, startupResult.stderr) - XCTAssertEqual(startupResult.status, 0, startupResult.stderr) - - // Don't read fakeSSHLog the instant the socket expectation resolves: the log - // content is written by a *separate* process (the fake ssh script's nested - // LocalCommand hop) than the one whose exit this test already waited on above, - // so its write can still be in flight even once the RPC round-trip completed. - // Reading immediately previously turned a scheduling delay into an uncaught - // "file not found" error (XCTest reports this as an "unexpected" failure, - // distinct from — and more confusing than — a plain assertion failure) instead - // of a clear, bounded diagnostic. Poll for the real completion signal we - // actually depend on: the log file existing with its expected line count. - let logIsReady = waitUntil(timeout: 15 * ciScale, description: "fake ssh log to record at least 2 invocations") { - guard let contents = try? String(contentsOf: fakeSSHLog, encoding: .utf8) else { return false } - return contents.split(separator: "\n").count >= 2 - } - XCTAssertTrue(logIsReady, "Expected fake ssh log at \(fakeSSHLog.path) to record at least 2 invocations in time") - - let logLines = try String(contentsOf: fakeSSHLog, encoding: .utf8) - .split(separator: "\n") - .map(String.init) - XCTAssertGreaterThanOrEqual(logLines.count, 2) - - let firstInvocationData = try XCTUnwrap(logLines.first?.data(using: .utf8)) - let firstInvocation = try XCTUnwrap( - JSONSerialization.jsonObject(with: firstInvocationData, options: []) as? [String] - ) - let localCommandArgument = try XCTUnwrap( - firstInvocation.first(where: { $0.hasPrefix("LocalCommand=") }) - ) - let localCommand = String(localCommandArgument.dropFirst("LocalCommand=".count)) - XCTAssertTrue( - firstInvocation.contains(where: { $0.contains("LocalCommand=") && $0.contains("workspace.remote.foreground_auth_ready") }), - "Expected the bootstrap install SSH hop to signal foreground auth readiness via LocalCommand, saw \(firstInvocation)" - ) - XCTAssertTrue( - localCommand.contains("%%s\\n"), - "Expected LocalCommand to percent-escape literal percent signs for OpenSSH, saw \(localCommand)" - ) - let localCommandSyntaxCheck = runProcess( - executablePath: "/bin/sh", - arguments: ["-n", "-c", localCommand], - environment: ProcessInfo.processInfo.environment, - timeout: 5 - ) - XCTAssertEqual( - localCommandSyntaxCheck.status, - 0, - "Expected LocalCommand shell snippet to parse cleanly, stderr: \(localCommandSyntaxCheck.stderr)" - ) - let destinationIndex = try XCTUnwrap(firstInvocation.lastIndex(of: "cmux-macmini")) - let remoteCommandArgs = Array(firstInvocation.suffix(from: firstInvocation.index(after: destinationIndex))) - - XCTAssertEqual( - remoteCommandArgs.count, - 1, - "Expected the staged bootstrap installer to be passed as one SSH remote command, saw \(firstInvocation)" - ) - XCTAssertTrue(remoteCommandArgs[0].contains("/bin/sh -c "), "Expected a POSIX shell wrapper in \(remoteCommandArgs)") - XCTAssertTrue(remoteCommandArgs[0].contains("set -eu"), "Expected installer command body in \(remoteCommandArgs)") - XCTAssertFalse(remoteCommandArgs.contains("sh")) - XCTAssertFalse(remoteCommandArgs.contains("-c")) - - let secondInvocationData = try XCTUnwrap(logLines.dropFirst().first?.data(using: .utf8)) - let secondInvocation = try XCTUnwrap( - JSONSerialization.jsonObject(with: secondInvocationData, options: []) as? [String] - ) - XCTAssertFalse( - secondInvocation.contains(where: { $0.contains("LocalCommand=") }), - "Expected only the bootstrap install hop to trigger LocalCommand, saw \(secondInvocation)" - ) - - XCTAssertEqual(foregroundAuthState.commands.count, 1) - let foregroundAuthPayloadData = try XCTUnwrap(foregroundAuthState.commands.first?.data(using: .utf8)) - let foregroundAuthPayload = try XCTUnwrap( - JSONSerialization.jsonObject(with: foregroundAuthPayloadData, options: []) as? [String: Any] - ) - XCTAssertEqual(foregroundAuthPayload["method"] as? String, "workspace.remote.foreground_auth_ready") - let foregroundAuthParams = try XCTUnwrap(foregroundAuthPayload["params"] as? [String: Any]) - XCTAssertEqual(foregroundAuthParams["workspace_id"] as? String, workspaceID) - XCTAssertEqual(foregroundAuthParams["foreground_auth_token"] as? String, foregroundAuthToken) - } - - @MainActor - func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("notify-tty") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let callerTTY = "/dev/ttys777" - let workspaceId = "11111111-1111-1111-1111-111111111111" - let callerSurface = "22222222-2222-2222-2222-222222222222" - let focusedSurface = "33333333-3333-3333-3333-333333333333" - let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" - let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - if line == "notify_target \(workspaceId) \(callerSurface) Notification||" { - return "OK" - } - - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return "ERROR: Unexpected command \(line)" - } - - let params = payload["params"] as? [String: Any] ?? [:] - switch method { - case "surface.list": - let requestedWorkspace = params["workspace_id"] as? String - if requestedWorkspace == staleWorkspace { - return self.v2Response( - id: id, - ok: false, - error: ["code": "not_found", "message": "Workspace not found"] - ) - } - if requestedWorkspace == workspaceId { - return self.v2Response( - id: id, - ok: true, - result: [ - "surfaces": [ - [ - "id": callerSurface, - "ref": "surface:1", - "index": 0, - "focused": false - ], - [ - "id": focusedSurface, - "ref": "surface:2", - "index": 1, - "focused": true - ] - ] - ] - ) - } - case "workspace.current": - return self.v2Response( - id: id, - ok: true, - result: ["workspace_id": workspaceId] - ) - case "debug.terminals": - return self.v2Response( - id: id, - ok: true, - result: [ - "count": 2, - "terminals": [ - [ - "workspace_id": workspaceId, - "surface_id": callerSurface, - "tty": callerTTY - ], - [ - "workspace_id": workspaceId, - "surface_id": focusedSurface, - "tty": "/dev/ttys778" - ] - ] - ] - ) - case "notification.create_for_target": - return self.v2Response(id: id, ok: true, result: [:]) - default: - break - } - - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_WORKSPACE_ID"] = staleWorkspace - environment["PROGRAMA_SURFACE_ID"] = staleSurface - environment["PROGRAMA_CLI_TTY_NAME"] = callerTTY - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["notify"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - let notifyRequests = state.commands.compactMap { line -> [String: Any]? in - guard let data = line.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } - XCTAssertTrue( - notifyRequests.contains { request in - guard request["method"] as? String == "notification.create_for_target" else { return false } - let params = request["params"] as? [String: Any] ?? [:] - return params["workspace_id"] as? String == workspaceId - && params["surface_id"] as? String == callerSurface - }, - "Expected notification.create_for_target to use caller tty surface, saw \(state.commands)" - ) - XCTAssertFalse( - notifyRequests.contains { request in - guard request["method"] as? String == "notification.create_for_target" else { return false } - let params = request["params"] as? [String: Any] ?? [:] - return params["workspace_id"] as? String == workspaceId - && params["surface_id"] as? String == focusedSurface - }, - "Focused surface should not win over caller tty, saw \(state.commands)" - ) - } - - @MainActor - func testNotifyInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("notify-tmux-tty") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let callerTTY = "/dev/ttys777" - let workspaceId = "11111111-1111-1111-1111-111111111111" - let callerSurface = "22222222-2222-2222-2222-222222222222" - let staleSurface = "33333333-3333-3333-3333-333333333333" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - if line == "notify_target \(workspaceId) \(callerSurface) Notification||" { - return "OK" - } - if line == "notify_target \(workspaceId) \(staleSurface) Notification||" { - return "OK" - } - - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return "ERROR: Unexpected command \(line)" - } - - let params = payload["params"] as? [String: Any] ?? [:] - switch method { - case "surface.list": - let requestedWorkspace = params["workspace_id"] as? String - if requestedWorkspace == workspaceId { - return self.v2Response( - id: id, - ok: true, - result: [ - "surfaces": [ - [ - "id": callerSurface, - "ref": "surface:1", - "index": 0, - "focused": false - ], - [ - "id": staleSurface, - "ref": "surface:2", - "index": 1, - "focused": true - ] - ] - ] - ) - } - case "debug.terminals": - return self.v2Response( - id: id, - ok: true, - result: [ - "count": 2, - "terminals": [ - [ - "workspace_id": workspaceId, - "surface_id": callerSurface, - "tty": callerTTY - ], - [ - "workspace_id": workspaceId, - "surface_id": staleSurface, - "tty": "/dev/ttys778" - ] - ] - ] - ) - case "notification.create_for_target": - return self.v2Response(id: id, ok: true, result: [:]) - default: - break - } - - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_WORKSPACE_ID"] = workspaceId - environment["PROGRAMA_SURFACE_ID"] = staleSurface - environment["PROGRAMA_CLI_TTY_NAME"] = callerTTY - environment["TMUX"] = "/tmp/tmux-current,123,0" - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["notify"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - let notifyRequests = state.commands.compactMap { line -> [String: Any]? in - guard let data = line.data(using: .utf8) else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } - XCTAssertTrue( - notifyRequests.contains { request in - guard request["method"] as? String == "notification.create_for_target" else { return false } - let params = request["params"] as? [String: Any] ?? [:] - return params["workspace_id"] as? String == workspaceId - && params["surface_id"] as? String == callerSurface - }, - "Expected notification.create_for_target to use caller tty surface in tmux, saw \(state.commands)" - ) - XCTAssertFalse( - notifyRequests.contains { request in - guard request["method"] as? String == "notification.create_for_target" else { return false } - let params = request["params"] as? [String: Any] ?? [:] - return params["workspace_id"] as? String == workspaceId - && params["surface_id"] as? String == staleSurface - }, - "Stale env surface should not win inside tmux, saw \(state.commands)" - ) - } - - @MainActor - func testTriggerFlashPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("flash-tty") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let callerTTY = "/dev/ttys777" - let workspaceId = "11111111-1111-1111-1111-111111111111" - let callerSurface = "22222222-2222-2222-2222-222222222222" - let focusedSurface = "33333333-3333-3333-3333-333333333333" - let staleWorkspace = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA" - let staleSurface = "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - let params = payload["params"] as? [String: Any] ?? [:] - switch method { - case "surface.list": - let requestedWorkspace = params["workspace_id"] as? String - if requestedWorkspace == staleWorkspace { - return self.v2Response( - id: id, - ok: false, - error: ["code": "not_found", "message": "Workspace not found"] - ) - } - if requestedWorkspace == workspaceId { - return self.v2Response( - id: id, - ok: true, - result: [ - "surfaces": [ - [ - "id": callerSurface, - "ref": "surface:1", - "index": 0, - "focused": false - ], - [ - "id": focusedSurface, - "ref": "surface:2", - "index": 1, - "focused": true - ] - ] - ] - ) - } - case "workspace.current": - return self.v2Response( - id: id, - ok: true, - result: ["workspace_id": workspaceId] - ) - case "debug.terminals": - return self.v2Response( - id: id, - ok: true, - result: [ - "count": 2, - "terminals": [ - [ - "workspace_id": workspaceId, - "surface_id": callerSurface, - "tty": callerTTY - ], - [ - "workspace_id": workspaceId, - "surface_id": focusedSurface, - "tty": "/dev/ttys778" - ] - ] - ] - ) - case "surface.trigger_flash": - let requestedWorkspace = params["workspace_id"] as? String - let requestedSurface = params["surface_id"] as? String - if requestedWorkspace == workspaceId, requestedSurface == callerSurface { - return self.v2Response(id: id, ok: true, result: [:]) - } - default: - break - } - - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_WORKSPACE_ID"] = staleWorkspace - environment["PROGRAMA_SURFACE_ID"] = staleSurface - environment["PROGRAMA_CLI_TTY_NAME"] = callerTTY - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["trigger-flash"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertTrue( - state.commands.contains { command in - guard let data = command.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let method = payload["method"] as? String, - method == "surface.trigger_flash" else { - return false - } - let params = payload["params"] as? [String: Any] ?? [:] - return (params["workspace_id"] as? String) == workspaceId - && (params["surface_id"] as? String) == callerSurface - }, - "Expected surface.trigger_flash to use caller tty surface, saw \(state.commands)" - ) - XCTAssertFalse( - state.commands.contains { command in - guard let data = command.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let method = payload["method"] as? String, - method == "surface.trigger_flash" else { - return false - } - let params = payload["params"] as? [String: Any] ?? [:] - return (params["workspace_id"] as? String) == workspaceId - && (params["surface_id"] as? String) == focusedSurface - }, - "Focused surface should not win over caller tty, saw \(state.commands)" - ) - } - - @MainActor - func testTriggerFlashInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("flash-tmux-tty") - let listenerFD = try bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let callerTTY = "/dev/ttys777" - let workspaceId = "11111111-1111-1111-1111-111111111111" - let callerSurface = "22222222-2222-2222-2222-222222222222" - let staleSurface = "33333333-3333-3333-3333-333333333333" - - defer { - Darwin.close(listenerFD) - unlink(socketPath) - } - - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let data = line.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let id = payload["id"] as? String, - let method = payload["method"] as? String else { - return self.v2Response( - id: "unknown", - ok: false, - error: ["code": "unexpected", "message": "Unexpected payload"] - ) - } - - let params = payload["params"] as? [String: Any] ?? [:] - switch method { - case "surface.list": - let requestedWorkspace = params["workspace_id"] as? String - if requestedWorkspace == workspaceId { - return self.v2Response( - id: id, - ok: true, - result: [ - "surfaces": [ - [ - "id": callerSurface, - "ref": "surface:1", - "index": 0, - "focused": false - ], - [ - "id": staleSurface, - "ref": "surface:2", - "index": 1, - "focused": true - ] - ] - ] - ) - } - case "debug.terminals": - return self.v2Response( - id: id, - ok: true, - result: [ - "count": 2, - "terminals": [ - [ - "workspace_id": workspaceId, - "surface_id": callerSurface, - "tty": callerTTY - ], - [ - "workspace_id": workspaceId, - "surface_id": staleSurface, - "tty": "/dev/ttys778" - ] - ] - ] - ) - case "surface.trigger_flash": - let requestedWorkspace = params["workspace_id"] as? String - let requestedSurface = params["surface_id"] as? String - if requestedWorkspace == workspaceId, - (requestedSurface == callerSurface || requestedSurface == staleSurface) { - return self.v2Response(id: id, ok: true, result: [:]) - } - default: - break - } - - return self.v2Response( - id: id, - ok: false, - error: ["code": "unexpected", "message": "Unexpected method \(method)"] - ) - } - - var environment = ProcessInfo.processInfo.environment - environment["PROGRAMA_SOCKET_PATH"] = socketPath - environment["PROGRAMA_WORKSPACE_ID"] = workspaceId - environment["PROGRAMA_SURFACE_ID"] = staleSurface - environment["PROGRAMA_CLI_TTY_NAME"] = callerTTY - environment["TMUX"] = "/tmp/tmux-current,123,0" - environment["PROGRAMA_CLI_SENTRY_DISABLED"] = "1" - environment["PROGRAMA_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" - - let result = runProcess( - executablePath: cliPath, - arguments: ["trigger-flash"], - environment: environment, - timeout: 5 - ) - - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertTrue( - state.commands.contains { command in - guard let data = command.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let method = payload["method"] as? String, - method == "surface.trigger_flash" else { - return false - } - let params = payload["params"] as? [String: Any] ?? [:] - return (params["workspace_id"] as? String) == workspaceId - && (params["surface_id"] as? String) == callerSurface - }, - "Expected trigger-flash to use caller tty surface in tmux, saw \(state.commands)" - ) - XCTAssertFalse( - state.commands.contains { command in - guard let data = command.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let method = payload["method"] as? String, - method == "surface.trigger_flash" else { - return false - } - let params = payload["params"] as? [String: Any] ?? [:] - return (params["workspace_id"] as? String) == workspaceId - && (params["surface_id"] as? String) == staleSurface - }, - "Stale env surface should not win inside tmux, saw \(state.commands)" - ) - } -} diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 5a4132c5..3ff5c709 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -4477,25 +4477,6 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } - func testSidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { - let workspace = Workspace() - - var publishCount = 0 - let cancellable = workspace.sidebarObservationPublisher.sink { - publishCount += 1 - } - defer { cancellable.cancel() } - - workspace.remoteHeartbeatCount = 1 - workspace.remoteLastHeartbeatAt = Date() - - XCTAssertEqual( - publishCount, - 0, - "Expected non-visible remote heartbeat updates to avoid invalidating sidebar rows" - ) - } - // Renamed from testSidebarPullRequestsTrackFocusedPanelOnly. That name asserted a // focused-only filter that never existed in production: `sidebarPullRequestsInDisplayOrder()` // has surfaced PRs for every panel in sidebar order since it was introduced (commit @@ -4672,53 +4653,6 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } - func testRemoteSidebarDirectoryCanonicalizationDedupesTildeAndAbsoluteHomePaths() { - let workspace = Workspace() - workspace.configureRemoteConnection( - WorkspaceRemoteConfiguration( - destination: "cmux-macmini", - port: nil, - identityFile: nil, - sshOptions: [], - localProxyPort: nil, - relayPort: 64007, - relayID: String(repeating: "a", count: 16), - relayToken: String(repeating: "b", count: 64), - localSocketPath: "/tmp/programa-debug-test.sock", - terminalStartupCommand: "ssh cmux-macmini" - ), - autoConnect: false - ) - - let liveDirectory = "/home/remoteuser/project" - let requestedDirectory = "~/project" - - guard let firstPanelId = workspace.focusedPanelId, - let paneId = workspace.paneId(forPanelId: firstPanelId), - let requestedPanel = workspace.newTerminalSurface( - inPane: paneId, - focus: false, - workingDirectory: requestedDirectory - ) else { - XCTFail("Expected remote panels for sidebar directory canonicalization test") - return - } - - workspace.updatePanelDirectory(panelId: firstPanelId, directory: liveDirectory) - - let orderedPanelIds = workspace.sidebarOrderedPanelIds() - XCTAssertEqual(orderedPanelIds, [firstPanelId, requestedPanel.id]) - - XCTAssertEqual( - workspace.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds), - [liveDirectory] - ) - XCTAssertEqual( - workspace.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds).map(\.directory), - [liveDirectory] - ) - } - func testSidebarDerivedCollectionsMatchWhenUsingPrecomputedPanelOrder() { let workspace = Workspace() guard let leftFirstPanelId = workspace.focusedPanelId, diff --git a/programaUITests/BrowserImportProfilesUITests.swift b/programaUITests/BrowserImportProfilesUITests.swift deleted file mode 100644 index e7f2b020..00000000 --- a/programaUITests/BrowserImportProfilesUITests.swift +++ /dev/null @@ -1,239 +0,0 @@ -import XCTest -import Foundation - -private func browserImportPollUntil( - timeout: TimeInterval, - pollInterval: TimeInterval = 0.05, - condition: () -> Bool -) -> Bool { - let start = ProcessInfo.processInfo.systemUptime - while true { - if condition() { - return true - } - if (ProcessInfo.processInfo.systemUptime - start) >= timeout { - return false - } - RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) - } -} - -final class BrowserImportProfilesUITests: XCTestCase { - private var capturePath = "" - - override func setUp() { - super.setUp() - continueAfterFailure = false - capturePath = "/tmp/programa-ui-test-browser-import-\(UUID().uuidString).json" - try? FileManager.default.removeItem(atPath: capturePath) - } - - func testMultipleSourceProfilesDefaultToSeparateDestinations() throws { - let app = launchApp() - - app.buttons["Next"].click() - app.buttons["Next"].click() - - XCTAssertTrue( - app.radioButtons["Separate profiles"].waitForExistence(timeout: 5.0), - "Expected Step 3 to show the separate-profiles default" - ) - XCTAssertTrue(app.radioButtons["Merge into one"].exists) - XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists) - XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists) - - app.buttons["Start Import"].click() - - let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) - XCTAssertEqual(capture["mode"] as? String, "separateProfiles") - XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory") - - let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]]) - XCTAssertEqual(entries.count, 2) - XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"]) - XCTAssertEqual(entries[0]["destinationKind"] as? String, "create") - XCTAssertEqual(entries[0]["destinationName"] as? String, "You") - XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"]) - XCTAssertEqual(entries[1]["destinationKind"] as? String, "create") - XCTAssertEqual(entries[1]["destinationName"] as? String, "austin") - } - - func testMergeModeCapturesSingleMergedDestination() throws { - let app = launchApp() - - app.buttons["Next"].click() - app.buttons["Next"].click() - - let mergeRadio = app.radioButtons["Merge into one"] - XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0)) - mergeRadio.click() - - XCTAssertTrue( - app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0), - "Expected merge mode to show the single destination popup" - ) - - app.buttons["Start Import"].click() - - let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) - XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne") - - let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]]) - XCTAssertEqual(entries.count, 1) - XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"]) - XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing") - XCTAssertEqual(entries[0]["destinationName"] as? String, "Default") - } - - func testAdditionalDataSelectionCapturesEverythingScope() throws { - let app = launchApp() - - app.buttons["Next"].click() - app.buttons["Next"].click() - - let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"] - XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0)) - cookiesCheckbox.click() - - let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"] - XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0)) - historyCheckbox.click() - - let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"] - XCTAssertTrue( - additionalDataCheckbox.waitForExistence(timeout: 5.0), - "Expected Step 3 to expose the additional data checkbox" - ) - additionalDataCheckbox.click() - - app.buttons["Start Import"].click() - - let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0)) - XCTAssertEqual(capture["scope"] as? String, "everything") - } - - func testBlankBrowserImportHintCanOpenBrowserSettings() { - let app = launchAppForBlankImportHint() - - let settingsButton = app.buttons["BrowserImportHintSettingsButton"] - XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) - settingsButton.click() - - let importSection = app.otherElements["SettingsBrowserImportSection"] - XCTAssertTrue( - importSection.waitForExistence(timeout: 5.0), - "Expected Browser Settings to scroll to the import section" - ) - - let chooseButton = app.buttons["SettingsBrowserImportChooseButton"] - XCTAssertTrue( - chooseButton.waitForExistence(timeout: 5.0), - "Expected Browser Settings to expose the import actions" - ) - XCTAssertTrue( - browserImportPollUntil(timeout: 5.0) { - importSection.isHittable && chooseButton.isHittable - }, - "Expected Browser Settings to scroll directly to the import controls" - ) - } - - func testBlankBrowserImportHintCanBeDismissed() { - let app = launchAppForBlankImportHint() - - let dismissButton = app.buttons["BrowserImportHintDismissButton"] - XCTAssertTrue(dismissButton.waitForExistence(timeout: 5.0)) - dismissButton.click() - - XCTAssertTrue( - browserImportPollUntil(timeout: 2.0) { !dismissButton.exists }, - "Expected the blank-tab import hint to disappear after dismissal" - ) - } - - private func launchApp() -> XCUIApplication { - let app = XCUIApplication() - app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"# - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"# - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1" - launchAndActivate(app) - openImportWizardFromBlankImportHint(app) - return app - } - - private func launchAppForBlankImportHint() -> XCUIApplication { - let app = XCUIApplication() - app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0" - app.launchEnvironment["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1" - launchAndActivate(app) - waitForBlankImportHint(app) - return app - } - - private func waitForImportWizard(_ app: XCUIApplication) { - let wizardOpened = browserImportPollUntil(timeout: 5.0) { - app.buttons["Next"].exists || app.windows["Import Browser Data"].exists - } - XCTAssertTrue(wizardOpened, "Expected the import wizard to open") - } - - private func waitForBlankImportHint(_ app: XCUIApplication) { - // The hint renders as a toolbar chip whose actions live in a popover. - let chip = app.buttons["BrowserImportHintToolbarChip"] - let chipAppeared = browserImportPollUntil(timeout: 5.0) { chip.exists } - XCTAssertTrue(chipAppeared, "Expected the blank browser import hint chip to appear") - chip.click() - let hintOpened = browserImportPollUntil(timeout: 5.0) { - app.buttons["BrowserImportHintImportButton"].exists - } - XCTAssertTrue(hintOpened, "Expected the import hint popover to open") - } - - private func openImportWizardFromBlankImportHint(_ app: XCUIApplication) { - waitForBlankImportHint(app) - - let importButton = app.buttons["BrowserImportHintImportButton"] - XCTAssertTrue(importButton.waitForExistence(timeout: 5.0)) - importButton.click() - - waitForImportWizard(app) - } - - private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? { - let url = URL(fileURLWithPath: capturePath) - let foundCapture = browserImportPollUntil(timeout: timeout) { - if let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return !object.isEmpty - } - return false - } - if foundCapture, - let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - return object - } - return nil - } - - private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { - app.launch() - let activated = browserImportPollUntil(timeout: activateTimeout) { - guard app.state != .runningForeground else { - return true - } - app.activate() - return app.state == .runningForeground - } - if !activated { - app.activate() - } - } -} diff --git a/scripts/build-ios-testflight.sh b/scripts/build-ios-testflight.sh deleted file mode 100755 index fb44e185..00000000 --- a/scripts/build-ios-testflight.sh +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Builds, signs and (optionally) uploads the iOS companion app to TestFlight. -# -# Signing is MANUAL here, unlike a local Xcode archive. CI has no authenticated -# Xcode account, so `-allowProvisioningUpdates` cannot create or refresh anything; -# the distribution certificate and both App Store profiles must arrive as -# secrets. That is also why this script derives the profile names from the -# imported profiles at runtime rather than hardcoding them: profile names change -# whenever they are regenerated in the portal, and a stale hardcoded name fails -# the export with an unhelpful error. -# -# Required environment: -# PROGRAMA_IOS_DIST_CERT_P12 path to the Apple Distribution .p12 -# PROGRAMA_IOS_DIST_CERT_PASSWORD its password -# PROGRAMA_IOS_APP_PROFILE path to the app's App Store .mobileprovision -# PROGRAMA_IOS_WIDGET_PROFILE path to the widget's App Store .mobileprovision -# PROGRAMA_IOS_TEAM_ID e.g. ZNHHMX2RP6 -# Optional: -# PROGRAMA_IOS_BUILD_NUMBER CFBundleVersion to stamp (defaults to 1) -# PROGRAMA_IOS_UPLOAD set to 1 to upload; otherwise export only -# PROGRAMA_ASC_KEY_ID / PROGRAMA_ASC_ISSUER_ID / PROGRAMA_ASC_KEY_P8 -# App Store Connect API key, required to upload - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -IOS_DIR="$ROOT_DIR/ios/ProgramaSpike" -WORK_DIR="${PROGRAMA_IOS_WORK_DIR:-$ROOT_DIR/.ios-build}" -ARCHIVE_PATH="$WORK_DIR/ProgramaSpike.xcarchive" -EXPORT_DIR="$WORK_DIR/export" - -APP_BUNDLE_ID="com.darkroom.programa.spike" -WIDGET_BUNDLE_ID="com.darkroom.programa.spike.widgets" - -require() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "Missing required environment variable: $name" >&2 - exit 1 - fi -} - -require PROGRAMA_IOS_DIST_CERT_P12 -require PROGRAMA_IOS_DIST_CERT_PASSWORD -require PROGRAMA_IOS_APP_PROFILE -require PROGRAMA_IOS_WIDGET_PROFILE -require PROGRAMA_IOS_TEAM_ID - -BUILD_NUMBER="${PROGRAMA_IOS_BUILD_NUMBER:-1}" - -rm -rf "$WORK_DIR" -mkdir -p "$WORK_DIR" "$EXPORT_DIR" - -# ---------------------------------------------------------------- keychain -KEYCHAIN="" -KEYCHAIN_PASSWORD="$(uuidgen)" -PRIOR_KEYCHAINS_FILE="$WORK_DIR/prior-user-keychains" -PRIOR_KEYCHAINS_CAPTURED=0 -PROFILE_RESTORE_MANIFEST="$WORK_DIR/profile-restore-manifest" -PROFILE_BACKUP_DIR="$WORK_DIR/profile-backups" -ASC_KEY_DEST="" -ASC_KEY_BACKUP="$WORK_DIR/asc-key-backup" -ASC_KEY_EXISTED=0 -ASC_KEY_INSTALLED=0 -ASC_KEY_DIR_CREATED=0 -: > "$PROFILE_RESTORE_MANIFEST" - -cleanup_signing_state() { - local original_status="$?" - local cleanup_failed=0 - local disposition destination backup mode - local -a prior_keychains=() - trap - EXIT - set +e - - while IFS=$'\t' read -r disposition destination backup mode; do - [[ -n "$destination" ]] || continue - if [[ "$disposition" == "restore" ]]; then - cp -p "$backup" "$destination" >/dev/null 2>&1 || { - echo "warning: could not restore provisioning profile: $destination" >&2 - cleanup_failed=1 - } - chmod "$mode" "$destination" >/dev/null 2>&1 || cleanup_failed=1 - else - rm -f "$destination" >/dev/null 2>&1 || { - echo "warning: could not remove installed provisioning profile: $destination" >&2 - cleanup_failed=1 - } - fi - done < "$PROFILE_RESTORE_MANIFEST" - - if (( ASC_KEY_INSTALLED )); then - if (( ASC_KEY_EXISTED )); then - cp -p "$ASC_KEY_BACKUP" "$ASC_KEY_DEST" >/dev/null 2>&1 || cleanup_failed=1 - else - rm -f "$ASC_KEY_DEST" >/dev/null 2>&1 || cleanup_failed=1 - if (( ASC_KEY_DIR_CREATED )); then - rmdir "$(dirname "$ASC_KEY_DEST")" >/dev/null 2>&1 - fi - fi - fi - - if (( PRIOR_KEYCHAINS_CAPTURED )); then - while IFS= read -r keychain; do - [[ -n "$keychain" ]] && prior_keychains+=("$keychain") - done < "$PRIOR_KEYCHAINS_FILE" - security list-keychains -d user -s "${prior_keychains[@]}" >/dev/null 2>&1 || { - echo "warning: could not restore the user keychain search list" >&2 - cleanup_failed=1 - } - fi - if [[ -n "$KEYCHAIN" ]]; then - security delete-keychain "$KEYCHAIN" >/dev/null 2>&1 || { - echo "warning: could not delete temporary build keychain: $KEYCHAIN" >&2 - cleanup_failed=1 - } - fi - if (( original_status == 0 && cleanup_failed )); then - original_status=1 - fi - exit "$original_status" -} -trap cleanup_signing_state EXIT - -security list-keychains -d user \ - | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' \ - > "$PRIOR_KEYCHAINS_FILE" -PRIOR_KEYCHAINS_CAPTURED=1 -KEYCHAIN="$WORK_DIR/ios-build-$$-$(uuidgen).keychain-db" -security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" -security set-keychain-settings -lut 21600 "$KEYCHAIN" -security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" -security import "$PROGRAMA_IOS_DIST_CERT_P12" -k "$KEYCHAIN" \ - -P "$PROGRAMA_IOS_DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null -prior_keychains=() -while IFS= read -r keychain; do - [[ -n "$keychain" ]] && prior_keychains+=("$keychain") -done < "$PRIOR_KEYCHAINS_FILE" -security list-keychains -d user -s "${prior_keychains[@]}" "$KEYCHAIN" - -SIGN_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \ - | grep -oE '"Apple Distribution: [^"]+"' | head -1 | tr -d '"')" -if [[ -z "$SIGN_IDENTITY" ]]; then - echo "No 'Apple Distribution' identity found in the imported certificate." >&2 - echo "A Developer ID or Apple Development cert cannot sign for TestFlight." >&2 - security find-identity -v -p codesigning "$KEYCHAIN" >&2 || true - exit 1 -fi -echo "Signing identity: $SIGN_IDENTITY" - -# ------------------------------------------------------- provisioning profiles -# Xcode looks for profiles by UUID filename in this directory (moved here in -# Xcode 16; the old ~/Library/MobileDevice path is no longer consulted). -PROFILE_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" -mkdir -p "$PROFILE_DIR" - -# PlistBuddy needs a real, seekable file. Handed /dev/stdin it does not just -# fail: it prints "Error Reading File: /dev/stdin" to STDOUT, so the caller's -# emptiness check sees a non-empty value and treats the error text as the field. -# That produced `cp ... "Error Reading File: /dev/stdin.mobileprovision"` and a -# "No such file or directory" a hundred lines away from the real cause. Decode -# to a temp file instead, and return empty on any failure so the callers' -# `-z` guards work. -profile_field() { - local src="$1" key="$2" plist - plist="$(mktemp "${TMPDIR:-/tmp}/programa-profile.XXXXXX")" - if security cms -D -i "$src" >"$plist" 2>/dev/null; then - /usr/libexec/PlistBuddy -c "Print $key" "$plist" 2>/dev/null || true - fi - rm -f "$plist" -} - -install_profile() { - local src="$1" - local output_variable="$2" - local uuid name destination backup mode existing_destination - uuid="$(profile_field "$src" ":UUID")" - name="$(profile_field "$src" ":Name")" - if [[ -z "$uuid" || -z "$name" ]]; then - echo "Could not read UUID/Name from profile: $src" >&2 - exit 1 - fi - if [[ ! "$uuid" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]]; then - echo "Profile has an invalid UUID: $src" >&2 - exit 1 - fi - destination="$PROFILE_DIR/$uuid.mobileprovision" - existing_destination=0 - while IFS=$'\t' read -r _ recorded_destination _ _; do - if [[ "$recorded_destination" == "$destination" ]]; then - existing_destination=1 - break - fi - done < "$PROFILE_RESTORE_MANIFEST" - if (( ! existing_destination )); then - if [[ -e "$destination" || -L "$destination" ]]; then - if [[ -L "$destination" ]]; then - echo "Refusing to replace symlinked provisioning profile: $destination" >&2 - exit 1 - fi - backup="$PROFILE_BACKUP_DIR/$uuid.mobileprovision" - mkdir -p "$PROFILE_BACKUP_DIR" - cp -p "$destination" "$backup" - mode="$(stat -f '%Lp' "$destination")" - printf 'restore\t%s\t%s\t%s\n' "$destination" "$backup" "$mode" >> "$PROFILE_RESTORE_MANIFEST" - else - printf 'remove\t%s\t\t\n' "$destination" >> "$PROFILE_RESTORE_MANIFEST" - fi - fi - cp "$src" "$destination" - printf -v "$output_variable" '%s' "$name" -} - -APP_PROFILE_NAME="" -WIDGET_PROFILE_NAME="" -install_profile "$PROGRAMA_IOS_APP_PROFILE" APP_PROFILE_NAME -install_profile "$PROGRAMA_IOS_WIDGET_PROFILE" WIDGET_PROFILE_NAME -echo "App profile: $APP_PROFILE_NAME" -echo "Widget profile: $WIDGET_PROFILE_NAME" - -# Fail early and loudly if the app profile does not grant production push. A -# TestFlight build without it installs and runs fine and silently receives no -# CloudKit pushes, which reads as "the companion app is broken" rather than as a -# signing problem. This exact gap was found by hand on the first build: the App -# Store profile predated Push Notifications being enabled on the App ID. -APP_PROFILE_APS="$(profile_field "$PROGRAMA_IOS_APP_PROFILE" ":Entitlements:aps-environment")" -if [[ "$APP_PROFILE_APS" != "production" ]]; then - echo "" >&2 - echo "FAIL: the app's App Store profile has aps-environment='${APP_PROFILE_APS:-<absent>}'," >&2 - echo "not 'production'. The build would receive no push notifications." >&2 - echo "Regenerate the profile with Push Notifications enabled on App ID $APP_BUNDLE_ID." >&2 - exit 1 -fi - -# ------------------------------------------------------------------- generate -if ! command -v xcodegen >/dev/null 2>&1; then - echo "xcodegen is required (brew install xcodegen)" >&2 - exit 1 -fi -# XcodeGen substitutes ${VAR} from the environment, which is how each target gets -# its own PROVISIONING_PROFILE_SPECIFIER. These must be exported before generate: -# a command-line build setting would apply to both targets, and the app and the -# widget sign with different profiles. -export PROGRAMA_IOS_APP_PROFILE_NAME="$APP_PROFILE_NAME" -export PROGRAMA_IOS_WIDGET_PROFILE_NAME="$WIDGET_PROFILE_NAME" -(cd "$IOS_DIR" && xcodegen generate) - -# Fail in seconds rather than after a ~10 minute archive and a consumed upload -# slot. An iPad-capable bundle (TARGETED_DEVICE_FAMILY "1,2") with no declared -# orientations passes the build with only a warning and then HARD FAILS App Store -# validation: "Invalid bundle. No orientations were specified". That cost two -# round trips on the first manual upload, including one that had already created -# the App Store Connect record. -SOURCE_PLIST="$IOS_DIR/ProgramaSpike/Info.plist" -DEVICE_FAMILY="$(grep -m1 'TARGETED_DEVICE_FAMILY' "$IOS_DIR/project.yml" | sed 's/.*"\(.*\)".*/\1/')" -if [[ "$DEVICE_FAMILY" == *"2"* ]]; then - for key in UISupportedInterfaceOrientations "UISupportedInterfaceOrientations~ipad"; do - if ! /usr/libexec/PlistBuddy -c "Print :$key" "$SOURCE_PLIST" >/dev/null 2>&1; then - echo "FAIL: $SOURCE_PLIST declares no :$key, but TARGETED_DEVICE_FAMILY is" >&2 - echo "'$DEVICE_FAMILY' (iPad-capable). App Store validation rejects that bundle." >&2 - echo "Declare it in project.yml under the app target's info.properties, or drop" >&2 - echo "TARGETED_DEVICE_FAMILY to \"1\" if the app should be iPhone-only." >&2 - exit 1 - fi - done - echo "Orientations declared for both iPhone and iPad." -fi - -# -------------------------------------------------------------------- archive -xcodebuild \ - -project "$IOS_DIR/ProgramaSpike.xcodeproj" \ - -scheme ProgramaSpike \ - -configuration Release \ - -destination 'generic/platform=iOS' \ - -archivePath "$ARCHIVE_PATH" \ - -clonedSourcePackagesDirPath "$WORK_DIR/source-packages" \ - CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ - DEVELOPMENT_TEAM="$PROGRAMA_IOS_TEAM_ID" \ - CODE_SIGN_STYLE=Manual \ - CODE_SIGN_IDENTITY="$SIGN_IDENTITY" \ - archive - -# --------------------------------------------------------------------- export -cat > "$WORK_DIR/ExportOptions.plist" <<PLIST -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>method</key> - <string>app-store-connect</string> - <key>teamID</key> - <string>$PROGRAMA_IOS_TEAM_ID</string> - <key>signingStyle</key> - <string>manual</string> - <key>signingCertificate</key> - <string>$SIGN_IDENTITY</string> - <key>provisioningProfiles</key> - <dict> - <key>$APP_BUNDLE_ID</key> - <string>$APP_PROFILE_NAME</string> - <key>$WIDGET_BUNDLE_ID</key> - <string>$WIDGET_PROFILE_NAME</string> - </dict> - <key>destination</key> - <string>export</string> - <key>uploadSymbols</key> - <true/> - <key>compileBitcode</key> - <false/> -</dict> -</plist> -PLIST - -xcodebuild -exportArchive \ - -archivePath "$ARCHIVE_PATH" \ - -exportOptionsPlist "$WORK_DIR/ExportOptions.plist" \ - -exportPath "$EXPORT_DIR" - -IPA_PATH="$(ls "$EXPORT_DIR"/*.ipa 2>/dev/null | head -1)" -if [[ -z "$IPA_PATH" ]]; then - echo "Export produced no .ipa" >&2 - exit 1 -fi -echo "Exported: $IPA_PATH" - -# ------------------------------------------------------ verify before upload -# Check the SIGNED entitlements, not the profile. The profile is what was -# requested; the signature is what the device enforces, and they can differ. -VERIFY_DIR="$WORK_DIR/verify" -rm -rf "$VERIFY_DIR"; mkdir -p "$VERIFY_DIR" -(cd "$VERIFY_DIR" && unzip -oq "$IPA_PATH") -SIGNED_APP="$VERIFY_DIR/Payload/ProgramaSpike.app" - -SIGNED_ENTS="$(codesign -d --entitlements - --xml "$SIGNED_APP" 2>/dev/null || true)" -check_entitlement() { - local key="$1" expected="$2" - local actual - # `plutil -extract` takes a KEYPATH, where an unescaped dot separates levels. - # Entitlement names are reverse-DNS, so a bare - # com.apple.developer.icloud-container-environment is read as com -> apple -> - # developer -> ... and never resolves, while dotless names like aps-environment - # and get-task-allow happen to work. That asymmetry made a correctly signed - # build look like it was missing the entitlement. Escape the dots. - local escaped="${key//./\\.}" - # plutil also writes "Could not extract value" to STDOUT rather than stderr, so - # a bare `|| echo "<absent>"` would report the error text as the value. - if ! actual="$(printf '%s' "$SIGNED_ENTS" | plutil -extract "$escaped" raw -o - - 2>/dev/null)"; then - actual="<absent>" - fi - case "$actual" in *"Could not extract value"*) actual="<absent>" ;; esac - if [[ "$actual" != "$expected" ]]; then - echo "FAIL: signed entitlement $key is '$actual', expected '$expected'" >&2 - return 1 - fi - echo " ok $key = $actual" -} - -echo "Verifying signed entitlements:" -verify_failed=0 -check_entitlement "aps-environment" "production" || verify_failed=1 -check_entitlement "get-task-allow" "false" || verify_failed=1 -check_entitlement "com.apple.developer.icloud-container-environment" "Production" || verify_failed=1 -# The build number has to actually reach the bundle, and for a long time it did -# not: xcodegen's default for CFBundleVersion is the literal "1", and because -# `info.path` sets INFOPLIST_FILE, Xcode substitutes only $(...) references -- -# so the CURRENT_PROJECT_VERSION passed on the archive command line was silently -# discarded and every build shipped as 1.0 (1). App Store Connect takes that -# once and rejects everything after it as a duplicate bundle version, which -# reads like a broken upload lane rather than a versioning bug. Check the built -# artifact, never the source. -echo "Verifying bundle version:" -check_bundle_version() { - local label="$1" plist="$2" actual - # plutil writes "Could not extract value" to STDOUT, so a bare fallback would - # report the error text as the value -- same trap as the entitlement checks. - if ! actual="$(plutil -extract CFBundleVersion raw -o - "$plist" 2>/dev/null)"; then - actual="<absent>" - fi - case "$actual" in *"Could not extract value"*) actual="<absent>" ;; esac - if [[ "$actual" != "$BUILD_NUMBER" ]]; then - echo "FAIL: $label CFBundleVersion is '$actual', expected '$BUILD_NUMBER'" >&2 - return 1 - fi - echo " ok $label CFBundleVersion = $actual" -} -check_bundle_version "app" "$SIGNED_APP/Info.plist" || verify_failed=1 -# An embedded extension whose CFBundleVersion differs from its host app fails -# App Store validation, so the widget is checked against the same number. -WIDGET_PLIST="$SIGNED_APP/PlugIns/ProgramaSpikeWidgets.appex/Info.plist" -if [[ -f "$WIDGET_PLIST" ]]; then - check_bundle_version "widget" "$WIDGET_PLIST" || verify_failed=1 -else - echo "FAIL: widget appex missing from the signed bundle" >&2 - verify_failed=1 -fi - -if (( verify_failed )); then - echo "Refusing to upload a build that would not behave correctly in TestFlight." >&2 - exit 1 -fi - -echo "IPA_PATH=$IPA_PATH" - -# --------------------------------------------------------------------- upload -if [[ "${PROGRAMA_IOS_UPLOAD:-0}" != "1" ]]; then - echo "PROGRAMA_IOS_UPLOAD is not 1; export only, not uploading." - exit 0 -fi - -require PROGRAMA_ASC_KEY_ID -require PROGRAMA_ASC_ISSUER_ID -require PROGRAMA_ASC_KEY_P8 - -# altool finds the key by convention: ./private_keys/AuthKey_<KEYID>.p8 relative -# to one of a fixed set of search paths. -KEY_DIR="$HOME/private_keys" -if [[ ! -d "$KEY_DIR" ]]; then - ASC_KEY_DIR_CREATED=1 -fi -mkdir -p "$KEY_DIR" -ASC_KEY_DEST="$KEY_DIR/AuthKey_${PROGRAMA_ASC_KEY_ID}.p8" -if [[ -e "$ASC_KEY_DEST" || -L "$ASC_KEY_DEST" ]]; then - if [[ -L "$ASC_KEY_DEST" ]]; then - echo "Refusing to replace symlinked App Store Connect key: $ASC_KEY_DEST" >&2 - exit 1 - fi - cp -p "$ASC_KEY_DEST" "$ASC_KEY_BACKUP" - ASC_KEY_EXISTED=1 -fi -cp "$PROGRAMA_ASC_KEY_P8" "$ASC_KEY_DEST" -chmod 600 "$ASC_KEY_DEST" -ASC_KEY_INSTALLED=1 - -xcrun altool --upload-app \ - --type ios \ - --file "$IPA_PATH" \ - --apiKey "$PROGRAMA_ASC_KEY_ID" \ - --apiIssuer "$PROGRAMA_ASC_ISSUER_ID" - -echo "Uploaded to App Store Connect. Processing takes a few minutes before it appears in TestFlight." diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh deleted file mode 100755 index 96710c1e..00000000 --- a/scripts/build_remote_daemon_release_assets.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/build_remote_daemon_release_assets.sh \ - --version <app-version> \ - --release-tag <tag> \ - --repo <owner/repo> \ - --output-dir <dir> \ - [--asset-suffix <suffix>] - -Builds programad-remote release assets for the supported remote platforms and emits: - programad-remote-<goos>-<goarch>[-<suffix>] - programad-remote-checksums[-<suffix>].txt - programad-remote-manifest[-<suffix>].json - -When --asset-suffix is provided, all output filenames and manifest download URLs -include the suffix, making each build's assets immutable (used by CI builds -to avoid checksum mismatches when assets are overwritten by later builds). -EOF -} - -VERSION="" -RELEASE_TAG="" -REPO="" -OUTPUT_DIR="" -ASSET_SUFFIX="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --version) - VERSION="${2:-}" - shift 2 - ;; - --release-tag) - RELEASE_TAG="${2:-}" - shift 2 - ;; - --repo) - REPO="${2:-}" - shift 2 - ;; - --output-dir) - OUTPUT_DIR="${2:-}" - shift 2 - ;; - --asset-suffix) - ASSET_SUFFIX="${2:-}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "error: unknown option $1" >&2 - usage - exit 1 - ;; - esac -done - -if [[ -z "$VERSION" || -z "$RELEASE_TAG" || -z "$REPO" || -z "$OUTPUT_DIR" ]]; then - echo "error: --version, --release-tag, --repo, and --output-dir are required" >&2 - usage - exit 1 -fi - -if ! command -v go >/dev/null 2>&1; then - echo "error: go is required to build programad-remote release assets" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -DAEMON_ROOT="${REPO_ROOT}/daemon/remote" -mkdir -p "$OUTPUT_DIR" -OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" -rm -f "$OUTPUT_DIR"/programad-remote-* "$OUTPUT_DIR"/programad-remote-checksums.txt "$OUTPUT_DIR"/programad-remote-manifest.json - -DAEMON_GO_LDFLAGS="-s -w -X main.version=${VERSION}" -DAEMON_GO_BUILD_ARGS=( - build - -trimpath - -buildvcs=false - -ldflags "$DAEMON_GO_LDFLAGS" -) - -SUFFIX_TAG="" -if [[ -n "$ASSET_SUFFIX" ]]; then - SUFFIX_TAG="-${ASSET_SUFFIX}" -fi - -CHECKSUMS_ASSET_NAME="programad-remote-checksums${SUFFIX_TAG}.txt" -CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" -MANIFEST_PATH="${OUTPUT_DIR}/programad-remote-manifest${SUFFIX_TAG}.json" - -TARGETS=( - "darwin arm64" - "darwin amd64" - "linux arm64" - "linux amd64" -) - -: > "$CHECKSUMS_PATH" -ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/programad-remote-entries.XXXXXX")" -trap 'rm -f "$ENTRIES_FILE"' EXIT -: > "$ENTRIES_FILE" - -for target in "${TARGETS[@]}"; do - read -r GOOS GOARCH <<<"$target" - ASSET_NAME="programad-remote-${GOOS}-${GOARCH}${SUFFIX_TAG}" - OUTPUT_PATH="${OUTPUT_DIR}/${ASSET_NAME}" - - # Build into a temp path first, then rename (the binary content is the same - # regardless of suffix, so we build once and move). - BUILD_PATH="${OUTPUT_DIR}/programad-remote-${GOOS}-${GOARCH}.build" - ( - cd "$DAEMON_ROOT" - GOOS="$GOOS" \ - GOARCH="$GOARCH" \ - CGO_ENABLED=0 \ - go "${DAEMON_GO_BUILD_ARGS[@]}" \ - -o "$BUILD_PATH" \ - ./cmd/programad-remote - ) - mv "$BUILD_PATH" "$OUTPUT_PATH" - chmod 755 "$OUTPUT_PATH" - - SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" - printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH" - - printf '%s\t%s\t%s\t%s\n' "$GOOS" "$GOARCH" "$ASSET_NAME" "$SHA256" >> "$ENTRIES_FILE" -done - -python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$REPO" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_FILE" -import json -import sys -import urllib.parse -from pathlib import Path - -version, release_tag, repo, checksums_asset_name, checksums_path, manifest_path, entries_file = sys.argv[1:] -quoted_tag = urllib.parse.quote(release_tag, safe="") -release_url = f"https://github.com/{repo}/releases/download/{quoted_tag}" -checksums_url = f"{release_url}/{urllib.parse.quote(checksums_asset_name, safe='')}" - -entries = [] -for line in Path(entries_file).read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - go_os, go_arch, asset_name, sha256 = line.split("\t") - entries.append({ - "goOS": go_os, - "goArch": go_arch, - "assetName": asset_name, - "downloadURL": f"{release_url}/{urllib.parse.quote(asset_name, safe='')}", - "sha256": sha256, - }) - -manifest = { - "schemaVersion": 1, - "appVersion": version, - "releaseTag": release_tag, - "releaseURL": release_url, - "checksumsAssetName": checksums_asset_name, - "checksumsURL": checksums_url, - "entries": entries, -} -Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") -PY - -echo "Built programad-remote assets in ${OUTPUT_DIR}" diff --git a/scripts/classify_ci_changes.sh b/scripts/classify_ci_changes.sh index 5a4e6e9f..7a5ea415 100755 --- a/scripts/classify_ci_changes.sh +++ b/scripts/classify_ci_changes.sh @@ -2,7 +2,6 @@ set -euo pipefail RUN_APP_JOBS=false -RUN_REMOTE_DAEMON_JOBS=false SAW_CHANGED_PATH=false while IFS= read -r path || [[ -n "$path" ]]; do @@ -30,9 +29,6 @@ while IFS= read -r path || [[ -n "$path" ]]; do .github/*) continue ;; - daemon/**) - RUN_REMOTE_DAEMON_JOBS=true - ;; *) RUN_APP_JOBS=true ;; @@ -41,8 +37,6 @@ done if [[ "$SAW_CHANGED_PATH" == "false" ]]; then RUN_APP_JOBS=true - RUN_REMOTE_DAEMON_JOBS=true fi printf 'run_app_jobs=%s\n' "$RUN_APP_JOBS" -printf 'run_remote_daemon_jobs=%s\n' "$RUN_REMOTE_DAEMON_JOBS" diff --git a/scripts/milestone_payload.js b/scripts/milestone_payload.js index 3597997e..b6d979c0 100644 --- a/scripts/milestone_payload.js +++ b/scripts/milestone_payload.js @@ -24,12 +24,6 @@ function payloadNames(build) { `programa-dSYMs-${build}.zip`, `programa-macos-${build}.dmg`, "programa-macos.dmg", - `programad-remote-checksums-${build}.txt`, - `programad-remote-darwin-amd64-${build}`, - `programad-remote-darwin-arm64-${build}`, - `programad-remote-linux-amd64-${build}`, - `programad-remote-linux-arm64-${build}`, - `programad-remote-manifest-${build}.json`, ]; } @@ -167,10 +161,6 @@ function validateMilestonePayloadReferences({ directory, build, repository, tag, })); return validateReleasePayloadReferences({ appcastXml: fs.readFileSync(path.join(directory, "appcast.xml"), "utf8"), - daemonManifestJson: fs.readFileSync( - path.join(directory, `programad-remote-manifest-${build}.json`), - "utf8", - ), repository, tag, manifest: { diff --git a/scripts/milestone_payload.test.js b/scripts/milestone_payload.test.js index f85de705..359a0c39 100644 --- a/scripts/milestone_payload.test.js +++ b/scripts/milestone_payload.test.js @@ -14,9 +14,9 @@ const path = require("node:path"); // verifyMilestonePayload({ directory, build }) -> manifest // // The manifest is `{ schemaVersion: 1, build, files }`, where `files` is the -// deterministic list of exactly ten milestone assets as +// deterministic list of exactly four milestone assets as // `{ name, size, sha256 }`. The written filename is -// `programa-milestone-payload.json`. Verification requires exactly those ten +// `programa-milestone-payload.json`. Verification requires exactly those four // payloads plus that manifest, validates its exact JSON schema, and hashes the // downloaded bytes rather than trusting metadata. const { @@ -34,12 +34,6 @@ function expectedNames(build = BUILD) { `programa-dSYMs-${build}.zip`, `programa-macos-${build}.dmg`, "programa-macos.dmg", - `programad-remote-checksums-${build}.txt`, - `programad-remote-darwin-amd64-${build}`, - `programad-remote-darwin-arm64-${build}`, - `programad-remote-linux-amd64-${build}`, - `programad-remote-linux-arm64-${build}`, - `programad-remote-manifest-${build}.json`, ]; } @@ -56,7 +50,7 @@ function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } -test("manifest creation records the exact ten milestone files and their bytes", (t) => { +test("manifest creation records the exact four milestone files and their bytes", (t) => { const directory = fixture(t); const manifest = createMilestoneManifest({ directory, build: BUILD }); @@ -123,7 +117,10 @@ test("verification rejects missing, extra, tampered, and wrong-build downloads", await t.test("wrong requested build", (t) => { const directory = fixture(t); writeMilestoneManifest({ directory, build: BUILD }); - assert.throws(() => verifyMilestonePayload({ directory, build: "41" }), /build|manifest/i); + assert.throws( + () => verifyMilestonePayload({ directory, build: "41" }), + /build|manifest|missing|unexpected/i, + ); }); }); diff --git a/scripts/publish_milestone_release.sh b/scripts/publish_milestone_release.sh index f704797c..c0322d54 100755 --- a/scripts/publish_milestone_release.sh +++ b/scripts/publish_milestone_release.sh @@ -113,12 +113,6 @@ done < "${EXPECTED_TSV}" SAFE_ORDER=( "programa-macos-${BUILD}.dmg" "programa-dSYMs-${BUILD}.zip" - "programad-remote-darwin-arm64-${BUILD}" - "programad-remote-darwin-amd64-${BUILD}" - "programad-remote-linux-arm64-${BUILD}" - "programad-remote-linux-amd64-${BUILD}" - "programad-remote-checksums-${BUILD}.txt" - "programad-remote-manifest-${BUILD}.json" "appcast.xml" "programa-macos.dmg" ) @@ -126,7 +120,7 @@ for name in "${SAFE_ORDER[@]}"; do [[ -n "${EXPECTED_SIZE[${name}]+x}" && -n "${EXPECTED_SHA[${name}]+x}" ]] || \ fail "local milestone manifest is missing ${name}" done -[[ "${#EXPECTED_SIZE[@]}" -eq 10 ]] || fail "local milestone manifest must describe exactly ten assets" +[[ "${#EXPECTED_SIZE[@]}" -eq 4 ]] || fail "local milestone manifest must describe exactly four assets" require_live_tag_target() { local live_target @@ -236,7 +230,7 @@ inspect_remote_assets() { fi done < "${metadata_file}" - if [[ "${require_complete}" == "true" && "${#PRESENT[@]}" -ne 10 ]]; then + if [[ "${require_complete}" == "true" && "${#PRESENT[@]}" -ne 4 ]]; then fail "published milestone release has a partial asset set" fi @@ -303,7 +297,7 @@ for name in "${SAFE_ORDER[@]}"; do done inspect_remote_assets "${TEMP_DIR}/converged-assets.tsv" "${TEMP_DIR}/converged-downloads" -[[ "${#PRESENT[@]}" -eq 10 ]] || fail "draft milestone release did not converge to ten assets" +[[ "${#PRESENT[@]}" -eq 4 ]] || fail "draft milestone release did not converge to four assets" # The immutable tag is checked again after uploads and immediately before publication. require_live_tag_target diff --git a/scripts/publish_release_candidate.sh b/scripts/publish_release_candidate.sh index 129209ab..5e81866e 100755 --- a/scripts/publish_release_candidate.sh +++ b/scripts/publish_release_candidate.sh @@ -2,7 +2,7 @@ set -euo pipefail readonly SEAL_NAME="programa-release-candidate.json" -readonly EXPECTED_ASSET_COUNT=10 +readonly EXPECTED_ASSET_COUNT=4 die() { echo "publish_release_candidate: $*" >&2 @@ -212,15 +212,12 @@ done manifest_path="${temp_dir}/${SEAL_NAME}" state_module="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/rolling_release_state.js" appcast_path="" -daemon_manifest_path="" for ((index = 0; index < EXPECTED_ASSET_COUNT; index++)); do case "${names[index]}" in appcast.xml) appcast_path="${paths[index]}" ;; - "programad-remote-manifest-${build}.json") daemon_manifest_path="${paths[index]}" ;; esac done [[ -n "${appcast_path}" ]] || die "payload set is missing appcast.xml" -[[ -n "${daemon_manifest_path}" ]] || die "payload set is missing programad-remote-manifest-${build}.json" node - \ "${state_module}" \ @@ -230,7 +227,6 @@ node - \ "${version}" \ "${build}" \ "${appcast_path}" \ - "${daemon_manifest_path}" \ "${GITHUB_REPOSITORY}" \ "${destination_tag}" <<'NODE' "use strict"; @@ -244,7 +240,6 @@ const [ version, build, appcastPath, - daemonManifestPath, repository, destinationTag, ] = process.argv.slice(2); @@ -275,7 +270,6 @@ const manifest = createCandidateManifest({ validateReleasePayloadReferences({ appcastXml: fs.readFileSync(appcastPath, "utf8"), - daemonManifestJson: fs.readFileSync(daemonManifestPath, "utf8"), repository, tag: destinationTag, manifest, @@ -467,12 +461,6 @@ done # Keep retry progress deterministic. Runtime payloads precede symbols and aliases. upload_payload_named "programa-macos-${build}.dmg" -upload_payload_named "programad-remote-darwin-arm64-${build}" -upload_payload_named "programad-remote-darwin-amd64-${build}" -upload_payload_named "programad-remote-linux-arm64-${build}" -upload_payload_named "programad-remote-linux-amd64-${build}" -upload_payload_named "programad-remote-checksums-${build}.txt" -upload_payload_named "programad-remote-manifest-${build}.json" upload_payload_named "programa-dSYMs-${build}.zip" upload_payload_named "appcast.xml" upload_payload_named "programa-macos.dmg" diff --git a/scripts/publish_rolling_release.sh b/scripts/publish_rolling_release.sh index 9e1cee87..edcf51a0 100755 --- a/scripts/publish_rolling_release.sh +++ b/scripts/publish_rolling_release.sh @@ -539,15 +539,13 @@ node - \ "${STATE_MODULE}" \ "${SELECTED_MANIFEST}" \ "${SELECTED_PAYLOAD_DIR}/appcast.xml" \ - "${SELECTED_PAYLOAD_DIR}/programad-remote-manifest-${SELECTED_BUILD}.json" \ "${REPOSITORY}" \ "${SELECTED_TAG}" <<'NODE' const fs = require("node:fs"); -const [modulePath, manifestPath, appcastPath, daemonManifestPath, repository, tag] = process.argv.slice(2); +const [modulePath, manifestPath, appcastPath, repository, tag] = process.argv.slice(2); const { validateReleasePayloadReferences } = require(modulePath); validateReleasePayloadReferences({ appcastXml: fs.readFileSync(appcastPath, "utf8"), - daemonManifestJson: fs.readFileSync(daemonManifestPath, "utf8"), repository, tag, manifest: require(manifestPath), diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js index 842a244d..18e6b0c7 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -3,12 +3,6 @@ const IMMUTABLE_RELEASE_ASSETS = [ "programa-macos.dmg", "appcast.xml", - "programad-remote-darwin-arm64", - "programad-remote-darwin-amd64", - "programad-remote-linux-arm64", - "programad-remote-linux-amd64", - "programad-remote-checksums.txt", - "programad-remote-manifest.json", ]; const RELEASE_ASSET_GUARD_STATE = Object.freeze({ CLEAR: "clear", diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js index cd97d5b8..95e7c77f 100644 --- a/scripts/release_asset_guard.test.js +++ b/scripts/release_asset_guard.test.js @@ -37,7 +37,7 @@ test("marks guard as clear when immutable assets are not present", () => { }); test("marks guard as partial when only some immutable assets exist", () => { - const partialAssets = ["appcast.xml", "programad-remote-manifest.json"]; + const partialAssets = ["appcast.xml"]; const result = evaluateReleaseAssetGuard({ existingAssetNames: partialAssets, }); diff --git a/scripts/restore_release_candidate.sh b/scripts/restore_release_candidate.sh index 5ca19a7c..079ea6fc 100755 --- a/scripts/restore_release_candidate.sh +++ b/scripts/restore_release_candidate.sh @@ -212,7 +212,7 @@ for (const asset of manifest.assets) { NODE cut -f2 "${metadata}" | LC_ALL=C sort > "${candidate_dir}/actual-names" { cut -f1 "${expected_tsv}"; printf '%s\n' "${SEAL_NAME}"; } | LC_ALL=C sort > "${candidate_dir}/expected-names" - cmp -s "${candidate_dir}/actual-names" "${candidate_dir}/expected-names" || fail "sealed candidate ${tag} does not contain exact ten payloads plus seal" + cmp -s "${candidate_dir}/actual-names" "${candidate_dir}/expected-names" || fail "sealed candidate ${tag} does not contain the exact payload set plus seal" while IFS=$'\t' read -r name size sha extra; do [[ -n "${name}" && -z "${extra:-}" ]] || fail "candidate ${tag} seal produced malformed asset metadata" @@ -220,13 +220,12 @@ NODE done < "${expected_tsv}" node - "${STATE_MODULE}" "${normalized}" "${payload_dir}/appcast.xml" \ - "${payload_dir}/programad-remote-manifest-${BUILD}.json" "${REPOSITORY}" "${DESTINATION_TAG}" <<'NODE' + "${REPOSITORY}" "${DESTINATION_TAG}" <<'NODE' const fs = require("node:fs"); -const [modulePath, manifestPath, appcastPath, daemonPath, repository, tag] = process.argv.slice(2); +const [modulePath, manifestPath, appcastPath, repository, tag] = process.argv.slice(2); const { validateReleasePayloadReferences } = require(modulePath); validateReleasePayloadReferences({ appcastXml: fs.readFileSync(appcastPath, "utf8"), - daemonManifestJson: fs.readFileSync(daemonPath, "utf8"), repository, tag, manifest: require(manifestPath), diff --git a/scripts/rolling_release_state.js b/scripts/rolling_release_state.js index ddf5be11..daf63041 100644 --- a/scripts/rolling_release_state.js +++ b/scripts/rolling_release_state.js @@ -47,12 +47,6 @@ function requiredImmutableNames(build) { return [ `programa-macos-${build}.dmg`, `programa-dSYMs-${build}.zip`, - `programad-remote-darwin-arm64-${build}`, - `programad-remote-darwin-amd64-${build}`, - `programad-remote-linux-arm64-${build}`, - `programad-remote-linux-amd64-${build}`, - `programad-remote-checksums-${build}.txt`, - `programad-remote-manifest-${build}.json`, ]; } @@ -135,7 +129,7 @@ function validateCandidateManifest(manifest) { } if (manifest.sealed) { - if (assets.length !== 10) throw new TypeError("sealed manifest must contain exactly 10 assets"); + if (assets.length !== 4) throw new TypeError("sealed manifest must contain exactly 4 assets"); if (appcast.length !== 1 || stableAliases.length !== 1) { throw new TypeError("sealed manifest must contain exactly one appcast and one stable alias"); } @@ -192,9 +186,6 @@ function selectPromotionCandidate(candidates) { const VERSIONED_ASSET_PATTERNS = [ /^programa-macos-([1-9][0-9]*)\.dmg$/, /^programa-dSYMs-([1-9][0-9]*)\.zip$/, - /^programad-remote-(?:darwin-arm64|darwin-amd64|linux-arm64|linux-amd64)-([1-9][0-9]*)$/, - /^programad-remote-checksums-([1-9][0-9]*)\.txt$/, - /^programad-remote-manifest-([1-9][0-9]*)\.json$/, ]; function buildFromAssetName(name) { @@ -544,13 +535,7 @@ function requireImmutableAsset(assetsByName, name, label) { return asset; } -function validateReleasePayloadReferences({ - appcastXml, - daemonManifestJson, - repository, - tag, - manifest, -}) { +function validateReleasePayloadReferences({ appcastXml, repository, tag, manifest }) { const normalizedManifest = validateCandidateManifest(manifest); if (!normalizedManifest.sealed) throw new TypeError("release payload manifest must be sealed"); assertSafeReleaseLocation(repository, tag); @@ -591,103 +576,9 @@ function validateReleasePayloadReferences({ ); } - if (typeof daemonManifestJson !== "string") { - throw new TypeError("daemon manifest JSON must be a string"); - } - let daemonManifest; - try { - daemonManifest = JSON.parse(daemonManifestJson); - } catch (error) { - throw new TypeError("daemon manifest contains malformed JSON", { cause: error }); - } - assertPlainObject(daemonManifest, "daemon manifest"); - assertExactFields( - daemonManifest, - [ - "schemaVersion", - "appVersion", - "releaseTag", - "releaseURL", - "checksumsAssetName", - "checksumsURL", - "entries", - ], - "daemon manifest", - ); - if (daemonManifest.schemaVersion !== 1) { - throw new TypeError("daemon manifest schemaVersion must be 1"); - } - if (daemonManifest.appVersion !== normalizedManifest.version) { - throw new TypeError("daemon manifest appVersion must equal the candidate marketing version"); - } - if (daemonManifest.releaseTag !== tag) { - throw new TypeError("daemon manifest releaseTag must equal the release tag"); - } - assertExactGitHubURL(daemonManifest.releaseURL, releaseURL, "daemon manifest releaseURL"); - - const checksumsName = `programad-remote-checksums-${normalizedManifest.build}.txt`; - if (daemonManifest.checksumsAssetName !== checksumsName) { - throw new TypeError("daemon manifest checksumsAssetName must match the candidate build"); - } - assertExactGitHubURL( - daemonManifest.checksumsURL, - `${releaseURL}/${checksumsName}`, - "daemon manifest checksumsURL", - ); - requireImmutableAsset(assetsByName, checksumsName, "daemon checksums"); - - if (!Array.isArray(daemonManifest.entries) || daemonManifest.entries.length !== 4) { - throw new TypeError("daemon manifest must contain exactly four platform entries"); - } - const expectedTargets = [ - ["darwin", "arm64"], - ["darwin", "amd64"], - ["linux", "arm64"], - ["linux", "amd64"], - ]; - const expectedKeys = new Set(expectedTargets.map(([goOS, goArch]) => `${goOS}/${goArch}`)); - const normalizedEntries = []; - - for (const [index, entry] of daemonManifest.entries.entries()) { - const label = `daemon manifest entry ${index}`; - assertPlainObject(entry, label); - assertExactFields(entry, ["goOS", "goArch", "assetName", "downloadURL", "sha256"], label); - const key = `${entry.goOS}/${entry.goArch}`; - if (!expectedKeys.delete(key)) { - throw new TypeError(`${label} has an unsupported or duplicate platform: ${key}`); - } - - const expectedName = `programad-remote-${entry.goOS}-${entry.goArch}-${normalizedManifest.build}`; - if (entry.assetName !== expectedName) { - throw new TypeError(`${label} assetName must match its platform and candidate build`); - } - assertExactGitHubURL(entry.downloadURL, `${releaseURL}/${expectedName}`, `${label} downloadURL`); - const sealedAsset = requireImmutableAsset(assetsByName, expectedName, label); - if (entry.sha256 !== sealedAsset.sha256) { - throw new TypeError(`${label} sha256 must equal the sealed asset hash`); - } - normalizedEntries.push({ - goOS: entry.goOS, - goArch: entry.goArch, - assetName: entry.assetName, - downloadURL: entry.downloadURL, - sha256: entry.sha256, - }); - } - if (expectedKeys.size !== 0) throw new TypeError("daemon manifest is missing a platform entry"); - return { manifest: normalizedManifest, appcast: { url: enclosureURL, build: normalizedManifest.build }, - daemonManifest: { - schemaVersion: 1, - appVersion: daemonManifest.appVersion, - releaseTag: daemonManifest.releaseTag, - releaseURL: daemonManifest.releaseURL, - checksumsAssetName: daemonManifest.checksumsAssetName, - checksumsURL: daemonManifest.checksumsURL, - entries: normalizedEntries, - }, }; } diff --git a/scripts/rolling_release_state.test.js b/scripts/rolling_release_state.test.js index 8f65625b..a98ae52f 100644 --- a/scripts/rolling_release_state.test.js +++ b/scripts/rolling_release_state.test.js @@ -25,8 +25,7 @@ const assert = require("node:assert/strict"); // // `role` is exactly "immutable", "appcast", or "stable-alias". Asset names // are safe basenames. A sealed manifest contains the complete rolling payload: -// one build-suffixed enclosure DMG, one dSYM archive, four build-suffixed daemon -// binaries, build-suffixed checksum and daemon manifest files, appcast.xml, and +// one build-suffixed enclosure DMG, one dSYM archive, appcast.xml, and // programa-macos.dmg. Asset size is a positive safe integer and sha256 is 64 // lowercase hexadecimal characters. Marketing `version` and monotonic `build` // are independent canonical identifiers. Every immutable filename suffix still @@ -62,12 +61,6 @@ function requiredAssets(build) { const assets = [ `programa-macos-${build}.dmg`, `programa-dSYMs-${build}.zip`, - `programad-remote-darwin-arm64-${build}`, - `programad-remote-darwin-amd64-${build}`, - `programad-remote-linux-arm64-${build}`, - `programad-remote-linux-amd64-${build}`, - `programad-remote-checksums-${build}.txt`, - `programad-remote-manifest-${build}.json`, ].map((name, index) => ({ name, role: "immutable", @@ -129,33 +122,9 @@ function legacyAttributeAppcast(build, enclosureBuild = build) { </rss>`; } -function daemonManifestFor(manifest, tag = "rolling") { - const releaseURL = `https://github.com/darkroomengineering/programa/releases/download/${tag}`; - const targets = [["darwin", "arm64"], ["darwin", "amd64"], ["linux", "arm64"], ["linux", "amd64"]]; - return JSON.stringify({ - schemaVersion: 1, - appVersion: manifest.version, - releaseTag: tag, - releaseURL, - checksumsAssetName: `programad-remote-checksums-${manifest.build}.txt`, - checksumsURL: `${releaseURL}/programad-remote-checksums-${manifest.build}.txt`, - entries: targets.map(([goOS, goArch]) => { - const assetName = `programad-remote-${goOS}-${goArch}-${manifest.build}`; - return { - goOS, - goArch, - assetName, - downloadURL: `${releaseURL}/${assetName}`, - sha256: manifest.assets.find((asset) => asset.name === assetName).sha256, - }; - }), - }); -} - function validateReferences(manifest, appcastXml, tag = "rolling") { return validateReleasePayloadReferences({ appcastXml, - daemonManifestJson: daemonManifestFor(manifest, tag), repository: "darkroomengineering/programa", tag, manifest, @@ -242,14 +211,14 @@ test("asset names are safe unique basenames and roles are exact", async (t) => { for (const [name, mutate] of mutations) { await t.test(name, () => { const value = manifestFor("41"); - mutate(value.assets[8]); + mutate(value.assets[2]); assert.throws(() => validateCandidateManifest(value), /asset|name|basename|path|role/i); }); } await t.test("duplicate name", () => { const value = manifestFor("41"); - value.assets[9].name = value.assets[8].name; + value.assets[3].name = value.assets[2].name; assert.throws(() => validateCandidateManifest(value), /duplicate|asset|name/i); }); }); @@ -371,7 +340,7 @@ test("candidate selection ignores incomplete unsealed drafts", () => { test("a corrupt highest sealed candidate fails closed instead of falling back", () => { const validLower = manifestFor("41"); const corruptHigher = manifestFor("42"); - corruptHigher.assets = corruptHigher.assets.filter((asset) => !asset.name.includes("linux-amd64")); + corruptHigher.assets = corruptHigher.assets.filter((asset) => !asset.name.includes("dSYMs")); assert.throws( () => selectPromotionCandidate([validLower, corruptHigher]), @@ -383,7 +352,7 @@ test("public high-water is the maximum build from rolling assets and readable ap const state = { rollingAssetNames: [ "programa-macos-900719925474099312345678901234567891.dmg", - "programad-remote-linux-arm64-900719925474099312345678901234567893", + "programa-dSYMs-900719925474099312345678901234567893.zip", "programa-macos.dmg", "appcast.xml", ], @@ -502,8 +471,7 @@ test("an archived candidate binds every build-specific URL to its exact permanen )); assert.throws( () => validateReleasePayloadReferences({ - appcastXml: appcast("41", "41", 902, VALID_ED25519_SIGNATURE, archiveTag), - daemonManifestJson: daemonManifestFor(manifest, "rolling-candidate-40"), + appcastXml: appcast("41", "41", 902, VALID_ED25519_SIGNATURE, "rolling-candidate-40"), repository: "darkroomengineering/programa", tag: archiveTag, manifest, @@ -626,7 +594,7 @@ test("every published milestone appcast contributes even when semantic tag order test("noncanonical build fragments in asset names do not become public high-water evidence", () => { assert.equal( derivePublicHighWater({ - rollingAssetNames: ["programa-macos-0042.dmg", "programad-remote-linux-arm64-1e3"], + rollingAssetNames: ["programa-macos-0042.dmg", "programa-dSYMs-1e3.zip"], rollingAppcastXml: null, publishedMilestoneAppcastXmls: [], }), diff --git a/scripts/sign-release-app.sh b/scripts/sign-release-app.sh index 67c69432..61c2435e 100755 --- a/scripts/sign-release-app.sh +++ b/scripts/sign-release-app.sh @@ -27,12 +27,6 @@ sign_if_present "$sparkle/XPCServices/Installer.xpc" sign_if_present "$sparkle/Updater.app" sign_if_present "$sparkle/Autoupdate" sign_if_present "$app_path/Contents/Frameworks/Sparkle.framework" -# Iroh ships as a prebuilt binary xcframework (mobile companion transport), so -# it arrives carrying an upstream signature we do not control. Re-signing the -# app without re-signing this leaves an inconsistent seal, and the final -# --verify --deep --strict fails with "code has no resources but signature -# indicates they must be present / In subcomponent: Iroh.framework". -sign_if_present "$app_path/Contents/Frameworks/Iroh.framework" sign_if_present "$app_path/Contents/PlugIns/ProgramaDockTilePlugin.plugin" sign_if_present "$app_path/Contents/Resources/bin/programa" sign_if_present "$app_path/Contents/Resources/bin/ghostty" diff --git a/scripts/sparkle_enclosure.test.js b/scripts/sparkle_enclosure.test.js index 57292cff..727eaa54 100644 --- a/scripts/sparkle_enclosure.test.js +++ b/scripts/sparkle_enclosure.test.js @@ -59,7 +59,6 @@ test("never prunes the stable download-button dmg or unrelated assets", () => { assetNames: [ STABLE_DMG_NAME, "appcast.xml", - "programad-remote-darwin-arm64", "programa-macos-100.dmg", "programa-macos-200.dmg", "programa-macos-300.dmg", diff --git a/tests/test_ci_change_classification.sh b/tests/test_ci_change_classification.sh index c29c84c3..93822c18 100755 --- a/tests/test_ci_change_classification.sh +++ b/tests/test_ci_change_classification.sh @@ -13,8 +13,7 @@ fi run_case() { local name="$1" local expected_app="$2" - local expected_daemon="$3" - local changed_paths="$4" + local changed_paths="$3" local output local expected @@ -23,10 +22,7 @@ run_case() { exit 1 fi - printf -v expected \ - 'run_app_jobs=%s\nrun_remote_daemon_jobs=%s' \ - "${expected_app}" \ - "${expected_daemon}" + printf -v expected 'run_app_jobs=%s' "${expected_app}" if [[ "${output}" != "${expected}" ]]; then echo "FAIL: ${name}: unexpected classifier output" >&2 @@ -36,52 +32,29 @@ run_case() { fi } -run_case \ - "app path before daemon path" \ - true \ - true \ - $'Sources/TerminalController.swift\ndaemon/remote/cmd/programad-remote/main.go\n' - -run_case \ - "daemon path before app path" \ - true \ - true \ - $'daemon/remote/cmd/programad-remote/main.go\nSources/TerminalController.swift\n' - -run_case \ - "daemon path only" \ - false \ - true \ - $'daemon/remote/cmd/programad-remote/main.go\n' - run_case \ "app path only" \ true \ - false \ $'Sources/TerminalController.swift\n' run_case \ "app icon asset" \ true \ - false \ $'Assets.xcassets/AppIcon.appiconset/icon.png\n' run_case \ "non-localization bundled image" \ true \ - false \ $'Resources/ghostty/themes/preview.png\n' run_case \ "documentation workflow and localization paths only" \ false \ - false \ $'docs/socket-control.md\nREADME.md\n.github/workflows/ci.yml\nResources/Localizable.xcstrings\nResources/ja.lproj/Localizable.strings\n' run_case \ "empty changed path set" \ true \ - true \ "" echo "CI change classification behavior: PASS" diff --git a/tests/test_cli_registry_behavior.py b/tests/test_cli_registry_behavior.py index 7e1218be..a4daf875 100755 --- a/tests/test_cli_registry_behavior.py +++ b/tests/test_cli_registry_behavior.py @@ -4,8 +4,6 @@ from __future__ import annotations import json -import hashlib -import hmac import os import socket import subprocess @@ -136,69 +134,6 @@ def _result_for(self, method: str, params: dict[str, Any]) -> dict[str, Any]: return {} -class RelayRecorder: - """Authenticated TCP relay that enforces the authoritative Programa handshake.""" - - relay_id = "relay-registry-test" - relay_token = "a1" * 32 - - def __init__(self) -> None: - self.errors: list[str] = [] - self.frames: list[dict[str, Any]] = [] - self.authenticated = False - self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._listener.bind(("127.0.0.1", 0)) - self._listener.listen(1) - self.address = f"127.0.0.1:{self._listener.getsockname()[1]}" - self._thread = threading.Thread(target=self._serve, daemon=True) - - def __enter__(self) -> RelayRecorder: - self._thread.start() - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self._listener.close() - self._thread.join(timeout=2.0) - if self._thread.is_alive(): - self.errors.append("relay recorder thread did not stop") - - @staticmethod - def _read_line(connection: socket.socket) -> bytes: - pending = b"" - while b"\n" not in pending: - chunk = connection.recv(8192) - if not chunk: - raise RuntimeError("connection closed before newline") - pending += chunk - return pending.split(b"\n", 1)[0] - - def _serve(self) -> None: - try: - connection, _ = self._listener.accept() - with connection: - nonce = "registry-nonce" - challenge = { - "protocol": "programa-relay-auth", - "version": 1, - "relay_id": self.relay_id, - "nonce": nonce, - } - connection.sendall(json.dumps(challenge).encode("utf-8") + b"\n") - auth = json.loads(self._read_line(connection)) - message = f"relay_id={self.relay_id}\nnonce={nonce}\nversion=1".encode() - expected = hmac.new(bytes.fromhex(self.relay_token), message, hashlib.sha256).hexdigest() - self.authenticated = hmac.compare_digest(str(auth.get("mac", "")), expected) - connection.sendall(json.dumps({"ok": self.authenticated}).encode("utf-8") + b"\n") - if not self.authenticated: - return - request = json.loads(self._read_line(connection)) - self.frames.append(request) - response = {"id": request.get("id"), "ok": True, "result": {"pong": True}} - connection.sendall(json.dumps(response).encode("utf-8") + b"\n") - except Exception as exc: # noqa: BLE001 - fixture records failures for the assertion - self.errors.append(f"relay recorder failed: {exc}") - - def run_cli( socket_path: str, args: list[str], @@ -635,25 +570,6 @@ def expect_without_connection( check(start_params.get("placement") == "runs_with_parent", f"Claude helper placement={start_params!r}") check(start_params.get("task") == "reviewer", f"Claude helper title={start_params!r}") - # TCP relays use the Programa-owned handshake identifier. A stale cmux - # protocol literal rejects the challenge before any v2 request is sent. - with RelayRecorder() as relay: - relayed_ping = run_cli( - relay.address, - ["ping"], - env_overrides={ - "PROGRAMA_RELAY_ID": relay.relay_id, - "PROGRAMA_RELAY_TOKEN": relay.relay_token, - }, - ) - check(not relay.errors, f"relay auth fixture errors: {relay.errors}") - check(relayed_ping.returncode == 0, f"relayed ping failed: {merged_output(relayed_ping)!r}") - check(relay.authenticated, "CLI did not complete programa-relay-auth handshake") - check( - [frame.get("method") for frame in relay.frames] == ["system.ping"], - f"relayed ping sent unexpected frames: {relay.frames!r}", - ) - # The implicit password file is security-sensitive: only a regular, # user-owned, private file may contribute an auth frame. def password_file_frames(kind: str) -> tuple[subprocess.CompletedProcess[str], list[dict[str, Any]]]: diff --git a/tests/test_milestone_release_publication.sh b/tests/test_milestone_release_publication.sh index ce012a3b..712ca4b3 100755 --- a/tests/test_milestone_release_publication.sh +++ b/tests/test_milestone_release_publication.sh @@ -42,12 +42,6 @@ payload_names() { printf '%s\n' \ "programa-macos-${build}.dmg" \ "programa-dSYMs-${build}.zip" \ - "programad-remote-darwin-arm64-${build}" \ - "programad-remote-darwin-amd64-${build}" \ - "programad-remote-linux-arm64-${build}" \ - "programad-remote-linux-amd64-${build}" \ - "programad-remote-checksums-${build}.txt" \ - "programad-remote-manifest-${build}.json" \ appcast.xml \ programa-macos.dmg } @@ -67,25 +61,6 @@ prepare_payload() { <sparkle:version>${build}</sparkle:version> <enclosure url="${release_url}/programa-macos-${build}.dmg" length="${enclosure_size}" sparkle:edSignature="${ED25519_SIGNATURE}" /> </item></channel></rss> -EOF - : > "${directory}/programad-remote-checksums-${build}.txt" - for name in \ - "programad-remote-darwin-arm64-${build}" "programad-remote-darwin-amd64-${build}" \ - "programad-remote-linux-arm64-${build}" "programad-remote-linux-amd64-${build}"; do - printf '%s %s\n' "$(sha256_file "${directory}/${name}")" "${name}" >> "${directory}/programad-remote-checksums-${build}.txt" - done - cat > "${directory}/programad-remote-manifest-${build}.json" <<EOF -{ - "schemaVersion":1,"appVersion":"${TAG#v}","releaseTag":"${TAG}","releaseURL":"${release_url}", - "checksumsAssetName":"programad-remote-checksums-${build}.txt", - "checksumsURL":"${release_url}/programad-remote-checksums-${build}.txt", - "entries":[ - {"goOS":"darwin","goArch":"arm64","assetName":"programad-remote-darwin-arm64-${build}","downloadURL":"${release_url}/programad-remote-darwin-arm64-${build}","sha256":"$(sha256_file "${directory}/programad-remote-darwin-arm64-${build}")"}, - {"goOS":"darwin","goArch":"amd64","assetName":"programad-remote-darwin-amd64-${build}","downloadURL":"${release_url}/programad-remote-darwin-amd64-${build}","sha256":"$(sha256_file "${directory}/programad-remote-darwin-amd64-${build}")"}, - {"goOS":"linux","goArch":"arm64","assetName":"programad-remote-linux-arm64-${build}","downloadURL":"${release_url}/programad-remote-linux-arm64-${build}","sha256":"$(sha256_file "${directory}/programad-remote-linux-arm64-${build}")"}, - {"goOS":"linux","goArch":"amd64","assetName":"programad-remote-linux-amd64-${build}","downloadURL":"${release_url}/programad-remote-linux-amd64-${build}","sha256":"$(sha256_file "${directory}/programad-remote-linux-amd64-${build}")"} - ] -} EOF node - "${MODULE}" "${directory}" "${build}" <<'NODE' const [modulePath, directory, build] = process.argv.slice(2); @@ -274,7 +249,7 @@ assert_converged() { while IFS= read -r name; do cmp -s "${payload_dir}/${name}" "$(asset_dir "${TAG}" "${name}")/bytes" || fail "remote bytes differ for ${name}" done < <(payload_names "${BUILD}") - [[ "$(find "$(release_dir "${TAG}")/assets" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" == 10 ]] || fail "remote asset set is not exact" + [[ "$(find "$(release_dir "${TAG}")/assets" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" == 4 ]] || fail "remote asset set is not exact" } PAYLOAD="${TMP_DIR}/payload-41" @@ -294,12 +269,12 @@ grep -Fq "read-tag-ref $(printf '%040x' 42)" "${STATE_DIR}/operations.log" || fa reset_state write_release "${TAG}" main true false "${TAG}" generated write_asset "${TAG}" "programa-macos-${BUILD}.dmg" "${PAYLOAD}/programa-macos-${BUILD}.dmg" -write_asset "${TAG}" "programad-remote-darwin-arm64-${BUILD}" "${PAYLOAD}/programad-remote-darwin-arm64-${BUILD}" +write_asset "${TAG}" "programa-dSYMs-${BUILD}.zip" "${PAYLOAD}/programa-dSYMs-${BUILD}.zip" : > "${STATE_DIR}/operations.log" invoke "${PAYLOAD}" assert_converged "${PAYLOAD}" main grep -Fq "read-tag-ref ${TARGET_SHA}" "${STATE_DIR}/operations.log" || fail "advisory-main recovery did not authenticate the live tag ref" -[[ "$(grep -c '^authenticated-download ' "${STATE_DIR}/operations.log")" -ge 10 ]] || fail "advisory-main recovery did not verify exact remote bytes" +[[ "$(grep -c '^authenticated-download ' "${STATE_DIR}/operations.log")" -ge 4 ]] || fail "advisory-main recovery did not verify exact remote bytes" # Local manifest verification runs before GitHub mutation. reset_state @@ -309,9 +284,9 @@ if invoke "${TMP_DIR}/tampered-local"; then fail "tampered local payload passed [[ ! -s "${STATE_DIR}/operations.log" ]] || fail "local verification failure mutated GitHub" # Matching file-manifest hashes cannot bless semantically invalid release -# payloads. Appcast and daemon references must match the tag, build, signature, -# enclosure length, checksums, and platform assets before GitHub mutation. -for semantic_conflict in appcast-url daemon-checksums-url; do +# payloads. Appcast references must match the tag, build, signature, and +# enclosure length before GitHub mutation. +for semantic_conflict in appcast-url; do semantic_dir="${TMP_DIR}/semantic-${semantic_conflict}" cp -R "${PAYLOAD}" "${semantic_dir}" case "${semantic_conflict}" in @@ -323,15 +298,6 @@ for semantic_conflict in appcast-url daemon-checksums-url; do </item></channel></rss> EOF ;; - daemon-checksums-url) - node - "${semantic_dir}/programad-remote-manifest-${BUILD}.json" <<'NODE' -const fs = require("node:fs"); -const path = process.argv[2]; -const value = JSON.parse(fs.readFileSync(path, "utf8")); -value.checksumsURL = "https://github.com/attacker/programa/releases/download/v1.2.3/checksums.txt"; -fs.writeFileSync(path, `${JSON.stringify(value)}\n`); -NODE - ;; esac rehash_manifest "${semantic_dir}" reset_state @@ -343,7 +309,7 @@ done reset_state; invoke "${PAYLOAD}"; assert_converged "${PAYLOAD}" uploads="$(sed -n 's/^mutation upload-asset [^ ]* //p' "${STATE_DIR}/operations.log")" [[ "$(printf '%s\n' "${uploads}" | tail -2)" == $'appcast.xml\nprograma-macos.dmg' ]] || fail "appcast and stable alias were not uploaded last" -[[ "$(grep -c '^authenticated-download ' "${STATE_DIR}/operations.log")" -ge 10 ]] || fail "remote payloads were not authenticated-download verified" +[[ "$(grep -c '^authenticated-download ' "${STATE_DIR}/operations.log")" -ge 4 ]] || fail "remote payloads were not authenticated-download verified" grep -Fq "view-release ${TAG} query=.isImmutable" "${STATE_DIR}/operations.log" || fail "publisher did not require immutable published state" # A published exact release is idempotent. @@ -371,11 +337,11 @@ if invoke "${PAYLOAD}" 2; then fail "early hard stop was not propagated"; fi [[ "$(cat "$(release_dir "${TAG}")/draft")" == true ]] || fail "early interruption did not preserve draft" rm -f "${STATE_DIR}/mutation_count"; invoke "${PAYLOAD}"; assert_converged "${PAYLOAD}" -# Hard stop after all ten uploads but before finalize resumes without clobber. +# Hard stop after every upload but before finalize resumes without clobber. reset_state -if invoke "${PAYLOAD}" 11; then fail "pre-finalize hard stop was not propagated"; fi +if invoke "${PAYLOAD}" 5; then fail "pre-finalize hard stop was not propagated"; fi [[ "$(cat "$(release_dir "${TAG}")/draft")" == true ]] || fail "complete interrupted release was published" -[[ "$(find "$(release_dir "${TAG}")/assets" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" == 10 ]] || fail "pre-finalize stop did not occur after all uploads" +[[ "$(find "$(release_dir "${TAG}")/assets" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" == 4 ]] || fail "pre-finalize stop did not occur after all uploads" : > "${STATE_DIR}/operations.log"; rm -f "${STATE_DIR}/mutation_count"; invoke "${PAYLOAD}"; assert_converged "${PAYLOAD}" ! grep -q '^mutation upload-asset ' "${STATE_DIR}/operations.log" || fail "complete draft retry reuploaded assets" diff --git a/tests/test_remote_daemon_release_assets.sh b/tests/test_remote_daemon_release_assets.sh deleted file mode 100755 index f05be984..00000000 --- a/tests/test_remote_daemon_release_assets.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -OUTPUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-test.XXXXXX")" -trap 'rm -rf "$OUTPUT_DIR"' EXIT - -"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \ - --version "0.62.0-test" \ - --release-tag "v0.62.0-test" \ - --repo "manaflow-ai/cmux" \ - --output-dir "$OUTPUT_DIR" >/dev/null - -for asset in \ - programad-remote-darwin-arm64 \ - programad-remote-darwin-amd64 \ - programad-remote-linux-arm64 \ - programad-remote-linux-amd64 \ - programad-remote-checksums.txt \ - programad-remote-manifest.json -do - if [[ ! -f "$OUTPUT_DIR/$asset" ]]; then - echo "FAIL: missing asset $asset" >&2 - exit 1 - fi -done - -python3 - <<'PY' "$OUTPUT_DIR/programad-remote-manifest.json" "$OUTPUT_DIR/programad-remote-checksums.txt" -import json -import sys -from pathlib import Path - -manifest_path = Path(sys.argv[1]) -checksums_path = Path(sys.argv[2]) -manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - -expected_targets = { - ("darwin", "arm64"), - ("darwin", "amd64"), - ("linux", "arm64"), - ("linux", "amd64"), -} -actual_targets = {(entry["goOS"], entry["goArch"]) for entry in manifest["entries"]} -if actual_targets != expected_targets: - raise SystemExit(f"FAIL: manifest targets {sorted(actual_targets)} != {sorted(expected_targets)}") - -if manifest["appVersion"] != "0.62.0-test": - raise SystemExit(f"FAIL: unexpected appVersion {manifest['appVersion']}") -if manifest["releaseTag"] != "v0.62.0-test": - raise SystemExit(f"FAIL: unexpected releaseTag {manifest['releaseTag']}") -if not manifest["checksumsURL"].endswith("/programad-remote-checksums.txt"): - raise SystemExit(f"FAIL: unexpected checksumsURL {manifest['checksumsURL']}") - -checksum_lines = [line for line in checksums_path.read_text(encoding="utf-8").splitlines() if line.strip()] -if len(checksum_lines) != 4: - raise SystemExit(f"FAIL: expected 4 checksum lines, got {len(checksum_lines)}") - -for entry in manifest["entries"]: - if not entry["downloadURL"].endswith("/" + entry["assetName"]): - raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}") - if len(entry["sha256"]) != 64: - raise SystemExit(f"FAIL: invalid sha256 for {entry['assetName']}") - -print("PASS: remote daemon release assets include all targets and manifest entries") -PY - -# ------------------------------------------------------------------ -# Test with --asset-suffix (nightly-style immutable asset names) -# ------------------------------------------------------------------ -SUFFIX_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-suffix-test.XXXXXX")" -trap 'rm -rf "$OUTPUT_DIR" "$SUFFIX_DIR"' EXIT - -"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \ - --version "0.62.0-nightly.123456" \ - --release-tag "nightly" \ - --repo "manaflow-ai/cmux" \ - --output-dir "$SUFFIX_DIR" \ - --asset-suffix "123456" >/dev/null - -for asset in \ - programad-remote-darwin-arm64-123456 \ - programad-remote-darwin-amd64-123456 \ - programad-remote-linux-arm64-123456 \ - programad-remote-linux-amd64-123456 \ - programad-remote-checksums-123456.txt \ - programad-remote-manifest-123456.json -do - if [[ ! -f "$SUFFIX_DIR/$asset" ]]; then - echo "FAIL: missing suffixed asset $asset" >&2 - exit 1 - fi -done - -python3 - <<'PY' "$SUFFIX_DIR/programad-remote-manifest-123456.json" -import json -import sys -from pathlib import Path - -manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) - -for entry in manifest["entries"]: - if not entry["assetName"].endswith("-123456"): - raise SystemExit(f"FAIL: suffixed asset name missing suffix: {entry['assetName']}") - if not entry["downloadURL"].endswith("/" + entry["assetName"]): - raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}") - -print("PASS: --asset-suffix produces correctly suffixed assets and manifest entries") -PY diff --git a/tests/test_rolling_release_publication.sh b/tests/test_rolling_release_publication.sh index 2856105f..cf9a4855 100755 --- a/tests/test_rolling_release_publication.sh +++ b/tests/test_rolling_release_publication.sh @@ -13,7 +13,7 @@ set -euo pipefail # Passing --prepare-only computes and writes the exact seal, then exits with no # GitHub mutation. A later normal invocation with that existing --seal-output # must verify the prepared bytes against the current payload before creating or -# changing a candidate. It uploads all ten payloads before uploading that exact +# changing a candidate. It uploads every payload before uploading that exact # prepared seal last. This two-phase contract applies to rolling and milestone # candidates through the same CLI. # @@ -25,7 +25,7 @@ set -euo pipefail # has the authoritative state-module shape `{schemaVersion:1,sealed:true, # targetSha,version,build,assets:[{name,role,size,sha256}]}`. Manifest sha256 # values are bare lowercase hex; GitHub asset metadata uses `sha256:<hex>`. -# Payload appcast and daemon-manifest URLs bind to the requested destination +# Payload appcast URLs bind to the requested destination # tag. Archive candidates use their permanent build tag; legacy rolling # candidate coverage still verifies an explicitly requested `rolling` target. # @@ -38,7 +38,7 @@ set -euo pipefail # persistent lock. It discovers the greatest sealed decimal build, validates # and downloads every candidate # asset through authenticated gh calls, then reconciles the rolling release. -# Before mutation it verifies the seal and all ten payload attestations against the release +# Before mutation it verifies the seal and all payload attestations against the release # workflow on refs/heads/main with self-hosted runners denied, and requires a # completed successful main-branch push CI run for the sealed target SHA. # Rolling's published build is a high-water mark: lower candidates cannot move @@ -166,27 +166,6 @@ make_fixture() { mkdir -p "${dir}" printf 'enclosure-%s\n' "${build}" > "${dir}/programa-macos-${build}.dmg" printf 'dsym-%s\n' "${build}" > "${dir}/programa-dSYMs-${build}.zip" - for daemon in darwin-arm64 darwin-amd64 linux-arm64 linux-amd64; do - printf 'daemon-%s-%s\n' "${daemon}" "${build}" > "${dir}/programad-remote-${daemon}-${build}" - done - printf 'checksums-%s\n' "${build}" > "${dir}/programad-remote-checksums-${build}.txt" - local release_url="https://github.com/${REPOSITORY}/releases/download/${destination}" - cat > "${dir}/programad-remote-manifest-${build}.json" <<EOF -{ - "schemaVersion": 1, - "appVersion": "${version}", - "releaseTag": "${destination}", - "releaseURL": "${release_url}", - "checksumsAssetName": "programad-remote-checksums-${build}.txt", - "checksumsURL": "${release_url}/programad-remote-checksums-${build}.txt", - "entries": [ - {"goOS":"darwin","goArch":"arm64","assetName":"programad-remote-darwin-arm64-${build}","downloadURL":"${release_url}/programad-remote-darwin-arm64-${build}","sha256":"$(sha256_file "${dir}/programad-remote-darwin-arm64-${build}")"}, - {"goOS":"darwin","goArch":"amd64","assetName":"programad-remote-darwin-amd64-${build}","downloadURL":"${release_url}/programad-remote-darwin-amd64-${build}","sha256":"$(sha256_file "${dir}/programad-remote-darwin-amd64-${build}")"}, - {"goOS":"linux","goArch":"arm64","assetName":"programad-remote-linux-arm64-${build}","downloadURL":"${release_url}/programad-remote-linux-arm64-${build}","sha256":"$(sha256_file "${dir}/programad-remote-linux-arm64-${build}")"}, - {"goOS":"linux","goArch":"amd64","assetName":"programad-remote-linux-amd64-${build}","downloadURL":"${release_url}/programad-remote-linux-amd64-${build}","sha256":"$(sha256_file "${dir}/programad-remote-linux-amd64-${build}")"} - ] -} -EOF cp "${dir}/programa-macos-${build}.dmg" "${dir}/programa-macos.dmg" local enclosure_size enclosure_size="$(file_size "${dir}/programa-macos-${build}.dmg")" @@ -226,35 +205,11 @@ write_invalid_appcast_item() { "${item}" > "${file}" } -poison_daemon_manifest_url() { - local build="$1" file="${FIXTURE_DIR}/$1/programad-remote-manifest-$1.json" - cat > "${file}" <<EOF -{ - "schemaVersion":1,"appVersion":"0.64.73","releaseTag":"rolling", - "releaseURL":"https://github.com/${REPOSITORY}/releases/download/rolling", - "checksumsAssetName":"programad-remote-checksums-${build}.txt", - "checksumsURL":"https://github.com/attacker/example/releases/download/not-rolling/programad-remote-checksums-${build}.txt", - "entries":[ - {"goOS":"darwin","goArch":"arm64","assetName":"programad-remote-darwin-arm64-${build}","downloadURL":"https://github.com/attacker/example/releases/download/not-rolling/programad-remote-darwin-arm64-${build}","sha256":"$(sha256_file "${FIXTURE_DIR}/${build}/programad-remote-darwin-arm64-${build}")"}, - {"goOS":"darwin","goArch":"amd64","assetName":"programad-remote-darwin-amd64-${build}","downloadURL":"https://github.com/${REPOSITORY}/releases/download/rolling/programad-remote-darwin-amd64-${build}","sha256":"$(sha256_file "${FIXTURE_DIR}/${build}/programad-remote-darwin-amd64-${build}")"}, - {"goOS":"linux","goArch":"arm64","assetName":"programad-remote-linux-arm64-${build}","downloadURL":"https://github.com/${REPOSITORY}/releases/download/rolling/programad-remote-linux-arm64-${build}","sha256":"$(sha256_file "${FIXTURE_DIR}/${build}/programad-remote-linux-arm64-${build}")"}, - {"goOS":"linux","goArch":"amd64","assetName":"programad-remote-linux-amd64-${build}","downloadURL":"https://github.com/${REPOSITORY}/releases/download/rolling/programad-remote-linux-amd64-${build}","sha256":"$(sha256_file "${FIXTURE_DIR}/${build}/programad-remote-linux-amd64-${build}")"} - ] -} -EOF -} - fixture_roles() { local build="$1" dir="${FIXTURE_DIR}/$1" printf '%s\n' \ "immutable=${dir}/programa-macos-${build}.dmg" \ "immutable=${dir}/programa-dSYMs-${build}.zip" \ - "immutable=${dir}/programad-remote-darwin-arm64-${build}" \ - "immutable=${dir}/programad-remote-darwin-amd64-${build}" \ - "immutable=${dir}/programad-remote-linux-arm64-${build}" \ - "immutable=${dir}/programad-remote-linux-amd64-${build}" \ - "immutable=${dir}/programad-remote-checksums-${build}.txt" \ - "immutable=${dir}/programad-remote-manifest-${build}.json" \ "appcast=${dir}/appcast.xml" \ "stable-alias=${dir}/programa-macos.dmg" } @@ -435,7 +390,7 @@ release_upload() { case "${corrupt}" in state:${name}) printf 'open\n' > "${dir}/state" ;; size:${name}) printf '1\n' > "${dir}/size" ;; digest:${name}) printf 'sha256:deadbeef\n' > "${dir}/digest" ;; esac mutation "upload-asset ${tag} ${name}" - if [[ "${tag}" == rolling && "${name}" == programad-remote-manifest-*.json && -n "${FAKE_GH_EXPOSE_MILESTONE_APPCAST:-}" ]]; then + if [[ "${tag}" == rolling && "${name}" == programa-dSYMs-*.zip && -n "${FAKE_GH_EXPOSE_MILESTONE_APPCAST:-}" ]]; then milestone_asset="$(asset_dir v0.63.0 appcast.xml)" cp "${FAKE_GH_EXPOSE_MILESTONE_APPCAST}" "${milestone_asset}/bytes" file_size "${FAKE_GH_EXPOSE_MILESTONE_APPCAST}" > "${milestone_asset}/size" @@ -711,12 +666,9 @@ assert_candidate_sealed() { if (Object.keys(value).sort().join(",") !== "assets,build,schemaVersion,sealed,targetSha,version") process.exit(1); const expectedSha = BigInt(build).toString(16).padStart(40, "0"); if (value.schemaVersion !== 1 || value.sealed !== true || value.build !== build || value.version !== version || value.targetSha !== expectedSha) process.exit(1); - if (!Array.isArray(value.assets) || value.assets.length !== 10) process.exit(1); + if (!Array.isArray(value.assets) || value.assets.length !== 4) process.exit(1); const expected = new Map([ [`programa-macos-${build}.dmg`, "immutable"], [`programa-dSYMs-${build}.zip`, "immutable"], - [`programad-remote-darwin-arm64-${build}`, "immutable"], [`programad-remote-darwin-amd64-${build}`, "immutable"], - [`programad-remote-linux-arm64-${build}`, "immutable"], [`programad-remote-linux-amd64-${build}`, "immutable"], - [`programad-remote-checksums-${build}.txt`, "immutable"], [`programad-remote-manifest-${build}.json`, "immutable"], ["appcast.xml", "appcast"], ["programa-macos.dmg", "stable-alias"], ]); for (const a of value.assets) { @@ -745,7 +697,7 @@ assert_published_archive() { assert_file_equals "$(release_dir "${tag}")/latest" false assert_file_equals "$(release_dir "${tag}")/prerelease" true assert_file_equals "$(release_dir "${tag}")/immutable" false - assert_asset_count "${tag}" 11 + assert_asset_count "${tag}" 5 } # A prepared seal is a durable handoff between build and staging. Preparation @@ -763,8 +715,8 @@ stage_prepared_candidate 104 0.64.73 assert_candidate_sealed 104 0.64.73 cmp -s "${TMP_DIR}/rolling-prepared-seal.snapshot" "${rolling_prepared_seal}" || fail "rolling staging rewrote the prepared seal" grep '^mutation upload-asset rolling-candidate-104 ' "${STATE_DIR}/operations.log" > "${TMP_DIR}/rolling-prepared-uploads" -[[ "$(wc -l < "${TMP_DIR}/rolling-prepared-uploads" | tr -d ' ')" == 11 ]] || fail "rolling prepared staging did not upload exact ten payloads plus seal" -! sed -n '1,10p' "${TMP_DIR}/rolling-prepared-uploads" | grep -Fq " ${SEAL_NAME}" || fail "rolling prepared seal was uploaded before all payloads" +[[ "$(wc -l < "${TMP_DIR}/rolling-prepared-uploads" | tr -d ' ')" == 5 ]] || fail "rolling prepared staging did not upload the exact payload set plus seal" +! sed -n '1,4p' "${TMP_DIR}/rolling-prepared-uploads" | grep -Fq " ${SEAL_NAME}" || fail "rolling prepared seal was uploaded before all payloads" [[ "$(tail -1 "${TMP_DIR}/rolling-prepared-uploads")" == *" ${SEAL_NAME}" ]] || fail "rolling prepared seal was not uploaded last" reset_state @@ -779,8 +731,8 @@ stage_prepared_candidate 201 1.2.3 milestone-candidate- v1.2.3 009 assert_candidate_sealed 201 1.2.3 milestone-candidate-201-009 cmp -s "${TMP_DIR}/milestone-prepared-seal.snapshot" "${milestone_prepared_seal}" || fail "milestone staging rewrote the prepared seal" grep '^mutation upload-asset milestone-candidate-201-009 ' "${STATE_DIR}/operations.log" > "${TMP_DIR}/milestone-prepared-uploads" -[[ "$(wc -l < "${TMP_DIR}/milestone-prepared-uploads" | tr -d ' ')" == 11 ]] || fail "milestone prepared staging did not upload exact ten payloads plus seal" -! sed -n '1,10p' "${TMP_DIR}/milestone-prepared-uploads" | grep -Fq " ${SEAL_NAME}" || fail "milestone prepared seal was uploaded before all payloads" +[[ "$(wc -l < "${TMP_DIR}/milestone-prepared-uploads" | tr -d ' ')" == 5 ]] || fail "milestone prepared staging did not upload the exact payload set plus seal" +! sed -n '1,4p' "${TMP_DIR}/milestone-prepared-uploads" | grep -Fq " ${SEAL_NAME}" || fail "milestone prepared seal was uploaded before all payloads" [[ "$(tail -1 "${TMP_DIR}/milestone-prepared-uploads")" == *" ${SEAL_NAME}" ]] || fail "milestone prepared seal was not uploaded last" # Both kinds of stale handoff fail before candidate mutation: altered seal @@ -817,13 +769,13 @@ printf '%s\n' "$(target_sha_for 201)" > "${STATE_DIR}/main_sha" RESTORED="${TMP_DIR}/restored-milestone"; mkdir -p "${RESTORED}" : > "${STATE_DIR}/operations.log"; invoke_restore "${RESTORED}" ! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "candidate restore mutated GitHub" -[[ "$(find "${RESTORED}" -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ')" == 11 ]] || fail "restore did not write exact ten payloads plus manifest" +[[ "$(find "${RESTORED}" -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ')" == 5 ]] || fail "restore did not write the exact payload set plus manifest" node - "${MILESTONE_MODULE}" "${RESTORED}" <<'NODE' const [modulePath, directory] = process.argv.slice(2); require(modulePath).verifyMilestonePayload({ directory, build: "201" }); NODE grep -Fxq "attestation-verify ${SEAL_NAME} source=$(target_sha_for 201)" "${STATE_DIR}/operations.log" || fail "restore did not attest the seal" -[[ "$(grep -c '^attestation-verify ' "${STATE_DIR}/operations.log")" == 11 ]] || fail "restore did not attest exact ten payloads plus seal" +[[ "$(grep -c '^attestation-verify ' "${STATE_DIR}/operations.log")" == 5 ]] || fail "restore did not attest the exact payload set plus seal" # Stored-byte tampering and failed provenance never reach the output directory. printf 'tampered\n' >> "$(asset_dir milestone-candidate-201-002 programa-macos-201.dmg)/bytes" @@ -868,14 +820,11 @@ grep -Fq 'authenticated-download rolling-candidate-100' "${STATE_DIR}/operations candidate_appcast="$(asset_dir rolling-candidate-100 appcast.xml)/bytes" grep -Fq '/releases/download/rolling/' "${candidate_appcast}" || fail "candidate appcast does not target rolling" ! grep -Fq '/releases/download/rolling-candidate-' "${candidate_appcast}" || fail "candidate appcast exposes candidate URL" -candidate_daemon_manifest="$(asset_dir rolling-candidate-100 programad-remote-manifest-100.json)/bytes" -grep -Fq '/releases/download/rolling/' "${candidate_daemon_manifest}" || fail "candidate daemon manifest does not target rolling" -! grep -Fq '/releases/download/rolling-candidate-' "${candidate_daemon_manifest}" || fail "candidate daemon manifest exposes candidate URL" # Decoy rolling text cannot conceal an operative URL to another repository or tag. -for poisoned_payload in appcast daemon-manifest; do +for poisoned_payload in appcast; do reset_state; make_fixture 105 0.64.73 - case "${poisoned_payload}" in appcast) poison_appcast_url 105 ;; daemon-manifest) poison_daemon_manifest_url 105 ;; esac + case "${poisoned_payload}" in appcast) poison_appcast_url 105 ;; esac if invoke_candidate 105 0.64.73; then fail "candidate staging accepted poisoned ${poisoned_payload} URL"; fi [[ ! -d "$(asset_dir rolling-candidate-105 "${SEAL_NAME}")" ]] || fail "poisoned ${poisoned_payload} candidate was sealed" done @@ -892,16 +841,16 @@ make_fixture 105 0.64.73 # Retry adds only missing assets and never clobbers existing exact bytes. reset_state -if invoke_candidate 101 0.64.73 'upload:rolling-candidate-101:programa-dSYMs-101.zip'; then fail "candidate interruption was not propagated"; fi +if invoke_candidate 101 0.64.73 'upload:rolling-candidate-101:appcast.xml'; then fail "candidate interruption was not propagated"; fi : > "${STATE_DIR}/operations.log"; invoke_candidate 101 0.64.73; assert_candidate_sealed 101 0.64.73 -for present in programad-remote-darwin-arm64-101 programa-macos-101.dmg; do +for present in programa-dSYMs-101.zip programa-macos-101.dmg; do ! grep -Eq "(delete-asset|upload-asset) rolling-candidate-101 ${present}$" "${STATE_DIR}/operations.log" || fail "retry clobbered ${present}" done # Reconciliation validates operative URLs again instead of trusting a sealed decoy. -for poisoned_payload in appcast daemon-manifest; do +for poisoned_payload in appcast; do reset_state; make_fixture 105 0.64.73 rolling-candidate-105 - case "${poisoned_payload}" in appcast) poison_appcast_url 105 ;; daemon-manifest) poison_daemon_manifest_url 105 ;; esac + case "${poisoned_payload}" in appcast) poison_appcast_url 105 ;; esac seed_sealed_candidate 105 0.64.73 false; seed_rolling 104 : > "${STATE_DIR}/operations.log" if invoke_rolling; then fail "reconciliation accepted poisoned ${poisoned_payload} URL"; fi @@ -914,9 +863,9 @@ done for field in state size digest; do reset_state; seed_sealed_candidate 102; seed_rolling 101 case "${field}" in - state) printf 'open\n' > "$(asset_dir rolling-candidate-102 programad-remote-darwin-arm64-102)/state" ;; - size) printf '1\n' > "$(asset_dir rolling-candidate-102 programad-remote-darwin-arm64-102)/size" ;; - digest) printf 'sha256:deadbeef\n' > "$(asset_dir rolling-candidate-102 programad-remote-darwin-arm64-102)/digest" ;; + state) printf 'open\n' > "$(asset_dir rolling-candidate-102 programa-dSYMs-102.zip)/state" ;; + size) printf '1\n' > "$(asset_dir rolling-candidate-102 programa-dSYMs-102.zip)/size" ;; + digest) printf 'sha256:deadbeef\n' > "$(asset_dir rolling-candidate-102 programa-dSYMs-102.zip)/digest" ;; esac : > "${STATE_DIR}/operations.log" if invoke_rolling; then fail "promotion accepted corrupt candidate ${field}"; fi @@ -928,10 +877,10 @@ done # Conflicting bytes and corrupt state/size/digest block the seal. reset_state write_release rolling-candidate-102 "$(target_sha_for 102)" true false 'Candidate 102' candidate; printf '502\n' > "$(release_dir rolling-candidate-102)/id" -wrong="${TMP_DIR}/wrong"; printf 'wrong\n' > "${wrong}"; write_asset rolling-candidate-102 programad-remote-darwin-arm64-102 "${wrong}" +wrong="${TMP_DIR}/wrong"; printf 'wrong\n' > "${wrong}"; write_asset rolling-candidate-102 programa-dSYMs-102.zip "${wrong}" if invoke_candidate 102 0.64.73; then fail "conflicting candidate bytes were accepted"; fi [[ ! -d "$(asset_dir rolling-candidate-102 "${SEAL_NAME}")" ]] || fail "conflicting candidate was sealed" -for corruption in state:programad-remote-darwin-arm64-103 size:programad-remote-darwin-arm64-103 digest:programad-remote-darwin-arm64-103; do +for corruption in state:programa-dSYMs-103.zip size:programa-dSYMs-103.zip digest:programa-dSYMs-103.zip; do reset_state if FAKE_GH_CORRUPT_UPLOAD="${corruption}" invoke_candidate 103 0.64.73; then fail "candidate with corrupt ${corruption%%:*} was sealed"; fi [[ ! -d "$(asset_dir rolling-candidate-103 "${SEAL_NAME}")" ]] || fail "corrupt candidate was sealed" @@ -947,7 +896,7 @@ expected_attestations="$(while IFS='=' read -r role path; do basename "${path}"; expected_attestations="$(printf '%s\n%s\n%s\n' "${expected_attestations}" "${SEAL_NAME}" "${SEAL_NAME}" | LC_ALL=C sort)" actual_attestations="$(sed -n 's/^attestation-verify \([^ ]*\) source=.*/\1/p' "${STATE_DIR}/operations.log" | LC_ALL=C sort)" [[ "${actual_attestations}" == "${expected_attestations}" ]] || \ - fail "reconciler did not attest ten payloads plus the seal before and after publication" + fail "reconciler did not attest every payload plus the seal before and after publication" expected_source="$(target_sha_for 103)" source_digest_count="$(grep -Fxc "attestation-verify programa-macos-103.dmg source=${expected_source}" "${STATE_DIR}/operations.log")" [[ "${source_digest_count}" == 1 ]] || fail "payload attestation was not bound to the selected target SHA" @@ -971,7 +920,7 @@ first_rolling_mutation="$(grep -n -E '^mutation (delete-asset|upload-asset|edit- # One failed payload attestation blocks every rolling mutation and retains the seal. reset_state seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" -if FAKE_GH_FAIL_ATTESTATION=programad-remote-checksums-103.txt invoke_rolling; then fail "failed payload attestation was ignored"; fi +if FAKE_GH_FAIL_ATTESTATION=programa-dSYMs-103.zip invoke_rolling; then fail "failed payload attestation was ignored"; fi assert_rolling_converged 102; assert_release_exists rolling-candidate-103 ! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || fail "attestation failure mutated rolling" diff --git a/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py b/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py deleted file mode 100644 index 3937b76a..00000000 --- a/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: remote browser favicon fetches must use the SSH proxy path.""" - -from __future__ import annotations - -import glob -import json -import os -import secrets -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() -SSH_PORT = os.environ.get("PROGRAMA_SSH_TEST_PORT", "").strip() -SSH_IDENTITY = os.environ.get("PROGRAMA_SSH_TEST_IDENTITY", "").strip() -SSH_OPTIONS_RAW = os.environ.get("PROGRAMA_SSH_TEST_OPTIONS", "").strip() - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - with cmux(SOCKET_PATH) as lookup_client: - listed = lookup_client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - resolved = str(row.get("id") or "") - if resolved: - return resolved - - current = {wid for _index, wid, _title, _focused in client.list_workspaces()} - new_ids = sorted(current - before_workspace_ids) - if len(new_ids) == 1: - return new_ids[0] - - raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 65.0) -> dict: - deadline = time.time() + timeout_s - last = {} - while time.time() < deadline: - last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last.get("remote") or {} - daemon = remote.get("daemon") or {} - proxy = remote.get("proxy") or {} - if ( - str(remote.get("state") or "") == "connected" - and str(daemon.get("state") or "") == "ready" - and str(proxy.get("state") or "") == "ready" - ): - return last - time.sleep(0.25) - raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") - - -def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - return str(payload.get("text") or "") - - -def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if token in _surface_scrollback_text(client, workspace_id, surface_id): - return - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for terminal token: {token}") - - -def _browser_body_text(client: cmux, surface_id: str) -> str: - payload = client._call( - "browser.eval", - { - "surface_id": surface_id, - "script": "document.body ? (document.body.innerText || '') : ''", - }, - ) or {} - return str(payload.get("value") or "") - - -def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: - deadline = time.time() + timeout_s - last_text = "" - while time.time() < deadline: - try: - last_text = _browser_body_text(client, surface_id) - except cmuxError: - time.sleep(0.2) - continue - if token in last_text: - return - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") - - -def _browser_favicon_state(client: cmux, surface_id: str) -> dict: - return dict(client._call("debug.browser.favicon", {"surface_id": surface_id}) or {}) - - -def _wait_browser_favicon(client: cmux, surface_id: str, timeout_s: float = 20.0) -> dict: - deadline = time.time() + timeout_s - last = {} - while time.time() < deadline: - try: - last = _browser_favicon_state(client, surface_id) - except cmuxError: - time.sleep(0.2) - continue - if bool(last.get("has_favicon")) and bool(str(last.get("png_base64") or "")): - return last - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for browser favicon state on {surface_id}: {last}") - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run remote favicon proxy regression") - return 0 - - cli = _find_cli_binary() - remote_workspace_id = "" - remote_surface_id = "" - server_script_path = "" - server_log_path = "" - hit_file_path = "" - - stamp = secrets.token_hex(4) - page_token = f"PROGRAMA_REMOTE_FAVICON_PAGE_{stamp}" - server_ready_token = f"PROGRAMA_REMOTE_FAVICON_READY_{stamp}" - default_web_port = 23000 + (os.getpid() % 4000) - ssh_web_port = int(os.environ.get("PROGRAMA_SSH_TEST_WEB_PORT", str(default_web_port))) - url = f"http://localhost:{ssh_web_port}/" - png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WewAAAABJRU5ErkJggg==" - server_script_path = f"/tmp/programa_remote_favicon_server_{stamp}.py" - server_log_path = f"/tmp/programa_remote_favicon_server_{stamp}.log" - hit_file_path = f"/tmp/programa_remote_favicon_hit_{stamp}" - - try: - with cmux(SOCKET_PATH) as setup_client: - before_workspace_ids = {wid for _index, wid, _title, _focused in setup_client.list_workspaces()} - - ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-favicon-{stamp}"] - if SSH_PORT: - ssh_args.extend(["--port", SSH_PORT]) - if SSH_IDENTITY: - ssh_args.extend(["--identity", SSH_IDENTITY]) - if SSH_OPTIONS_RAW: - for option in SSH_OPTIONS_RAW.split(","): - trimmed = option.strip() - if trimmed: - ssh_args.extend(["--ssh-option", trimmed]) - - payload = _run_cli_json(cli, ssh_args) - - with cmux(SOCKET_PATH) as client: - remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) - _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) - - surfaces = client.list_surfaces(remote_workspace_id) - _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") - remote_surface_id = str(surfaces[0][1]) - - server_script = f"""cat > {server_script_path} <<'PY' -import base64 -import sys -from http.server import BaseHTTPRequestHandler, HTTPServer - -PORT = int(sys.argv[1]) -HIT_FILE = sys.argv[2] -PAGE_TOKEN = sys.argv[3] -PNG = base64.b64decode(sys.argv[4].encode("ascii")) - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path.startswith("/favicon.ico"): - with open(HIT_FILE, "w", encoding="utf-8") as f: - f.write("hit\\n") - self.send_response(200) - self.send_header("Content-Type", "image/png") - self.send_header("Content-Length", str(len(PNG))) - self.end_headers() - self.wfile.write(PNG) - return - - body = ( - "<!doctype html><html><head>" - "<link rel=\\"icon\\" href=\\"/favicon.ico?via=cmux\\">" - f"</head><body>{{PAGE_TOKEN}}</body></html>" - ).replace("{{PAGE_TOKEN}}", PAGE_TOKEN) - data = body.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def log_message(self, fmt, *args): - return - -HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() -PY -rm -f {hit_file_path} {server_log_path} -python3 {server_script_path} {ssh_web_port} {hit_file_path} {page_token} {png_base64} >{server_log_path} 2>&1 & -for _ in $(seq 1 30); do - if curl -fsS http://localhost:{ssh_web_port}/ | grep -q {page_token}; then - echo {server_ready_token} - break - fi - sleep 0.2 -done""" - client._call( - "surface.send_text", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, - ) - client._call( - "surface.send_key", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, - ) - _wait_surface_contains(client, remote_workspace_id, remote_surface_id, server_ready_token, timeout_s=12.0) - - browser_payload = client._call( - "browser.open_split", - {"workspace_id": remote_workspace_id, "url": url}, - ) or {} - browser_surface_id = str(browser_payload.get("surface_id") or "") - _must(browser_surface_id, f"browser.open_split returned no surface_id: {browser_payload}") - - _wait_browser_contains(client, browser_surface_id, page_token, timeout_s=20.0) - - favicon_state = _wait_browser_favicon(client, browser_surface_id, timeout_s=14.0) - _must(bool(favicon_state.get("has_favicon")), f"browser favicon state never became ready: {favicon_state}") - _must(bool(str(favicon_state.get('png_base64') or "")), f"browser favicon PNG payload missing: {favicon_state}") - - print("PASS: remote browser favicon state loads for remote localhost pages over the SSH proxy") - return 0 - finally: - if remote_surface_id and remote_workspace_id: - try: - cleanup = ( - f"pkill -f {server_script_path} >/dev/null 2>&1 || true; " - f"rm -f {server_script_path} {server_log_path} {hit_file_path}" - ) - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client._call( - "surface.send_text", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, - ) - cleanup_client._call( - "surface.send_key", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, - ) - except Exception: # noqa: BLE001 - pass - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py deleted file mode 100644 index ea788360..00000000 --- a/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: moving a browser surface into an SSH workspace must rebind remote proxy state.""" - -from __future__ import annotations - -import glob -import json -import os -import secrets -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() -SSH_PORT = os.environ.get("PROGRAMA_SSH_TEST_PORT", "").strip() -SSH_IDENTITY = os.environ.get("PROGRAMA_SSH_TEST_IDENTITY", "").strip() -SSH_OPTIONS_RAW = os.environ.get("PROGRAMA_SSH_TEST_OPTIONS", "").strip() - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - resolved = str(row.get("id") or "") - if resolved: - return resolved - - current = {wid for _index, wid, _title, _focused in client.list_workspaces()} - new_ids = sorted(current - before_workspace_ids) - if len(new_ids) == 1: - return new_ids[0] - - raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 60.0) -> dict: - deadline = time.time() + timeout_s - last = {} - while time.time() < deadline: - last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last.get("remote") or {} - daemon = remote.get("daemon") or {} - proxy = remote.get("proxy") or {} - if ( - str(remote.get("state") or "") == "connected" - and str(daemon.get("state") or "") == "ready" - and str(proxy.get("state") or "") == "ready" - ): - return last - time.sleep(0.25) - raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") - - -def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - return str(payload.get("text") or "") - - -def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if token in _surface_scrollback_text(client, workspace_id, surface_id): - return - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for remote terminal token: {token}") - - -def _browser_body_text(client: cmux, surface_id: str) -> str: - payload = client._call( - "browser.eval", - { - "surface_id": surface_id, - "script": "document.body ? (document.body.innerText || '') : ''", - }, - ) or {} - return str(payload.get("value") or "") - - -def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: - deadline = time.time() + timeout_s - last_text = "" - while time.time() < deadline: - try: - last_text = _browser_body_text(client, surface_id) - except cmuxError: - time.sleep(0.2) - continue - if token in last_text: - return - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") - - -def _assert_browser_does_not_contain(client: cmux, surface_id: str, token: str, sample_window_s: float = 6.0) -> str: - deadline = time.time() + sample_window_s - last_text = "" - while time.time() < deadline: - try: - last_text = _browser_body_text(client, surface_id) - except cmuxError: - time.sleep(0.2) - continue - if token in last_text: - raise cmuxError( - f"browser unexpectedly loaded remote marker before SSH proxy rebind; token={token!r} body={last_text[:240]!r}" - ) - time.sleep(0.2) - return last_text - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run remote browser move/proxy regression") - return 0 - - cli = _find_cli_binary() - remote_workspace_id = "" - remote_surface_id = "" - - stamp = secrets.token_hex(4) - marker_file = f"PROGRAMA_REMOTE_PROXY_MOVE_{stamp}.txt" - marker_body = f"PROGRAMA_REMOTE_PROXY_BODY_{stamp}" - ready_token = f"PROGRAMA_HTTP_READY_{stamp}" - default_web_port = 20000 + (os.getpid() % 5000) - ssh_web_port = int(os.environ.get("PROGRAMA_SSH_TEST_WEB_PORT", str(default_web_port))) - url = f"http://localhost:{ssh_web_port}/{marker_file}" - - try: - with cmux(SOCKET_PATH) as client: - before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} - - browser_surface_id = client.open_browser("about:blank") - _must(bool(browser_surface_id), "browser.open_split returned no surface") - - ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-move-proxy-{stamp}"] - if SSH_PORT: - ssh_args.extend(["--port", SSH_PORT]) - if SSH_IDENTITY: - ssh_args.extend(["--identity", SSH_IDENTITY]) - if SSH_OPTIONS_RAW: - for option in SSH_OPTIONS_RAW.split(","): - trimmed = option.strip() - if trimmed: - ssh_args.extend(["--ssh-option", trimmed]) - - payload = _run_cli_json(cli, ssh_args) - remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) - remote_status = _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) - remote_payload = remote_status.get("remote") or {} - forwarded_ports = remote_payload.get("forwarded_ports") or [] - _must( - forwarded_ports == [], - f"remote workspace should rely on proxy endpoint, not explicit forwarded ports: {forwarded_ports!r}", - ) - - surfaces = client.list_surfaces(remote_workspace_id) - _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") - remote_surface_id = str(surfaces[0][1]) - - server_script = ( - f"printf '%s\\n' {marker_body} > /tmp/{marker_file}; " - f"python3 -m http.server {ssh_web_port} --directory /tmp >/tmp/programa-remote-browser-proxy-{stamp}.log 2>&1 & " - "for _ in $(seq 1 30); do " - f" if curl -fsS http://localhost:{ssh_web_port}/{marker_file} | grep -q {marker_body}; then " - f" echo {ready_token}; " - " break; " - " fi; " - " sleep 0.2; " - "done" - ) - client._call( - "surface.send_text", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, - ) - client._call( - "surface.send_key", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, - ) - _wait_surface_contains(client, remote_workspace_id, remote_surface_id, ready_token, timeout_s=12.0) - - browser_surface_id = str(client._resolve_surface_id(browser_surface_id)) - client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) - local_body = _assert_browser_does_not_contain(client, browser_surface_id, marker_body, sample_window_s=5.0) - _must( - marker_body not in local_body, - f"browser should not reach remote localhost before moving into ssh workspace: {local_body[:240]!r}", - ) - - client.move_surface(browser_surface_id, workspace=remote_workspace_id, focus=True) - - def _browser_in_remote_workspace() -> bool: - for _idx, sid, _focused in client.list_surfaces(remote_workspace_id): - if str(sid) == browser_surface_id: - return True - return False - - _wait_for(_browser_in_remote_workspace, timeout_s=10.0, step_s=0.15) - - client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) - _wait_browser_contains(client, browser_surface_id, marker_body, timeout_s=20.0) - - body = _browser_body_text(client, browser_surface_id) - _must(marker_body in body, f"browser did not load remote localhost content over SSH proxy: {body[:240]!r}") - _must("Can't reach this page" not in body, f"browser rendered local error page instead of remote content: {body[:240]!r}") - - print( - "PASS: browser proxy stays scoped to SSH workspace surfaces, uses proxy endpoint without explicit forwarded ports, " - "and reaches remote localhost after move" - ) - return 0 - finally: - if remote_surface_id and remote_workspace_id: - try: - cleanup = f"pkill -f 'python3 -m http.server {ssh_web_port}' >/dev/null 2>&1 || true" - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client._call( - "surface.send_text", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, - ) - cleanup_client._call( - "surface.send_key", - {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, - ) - except Exception: # noqa: BLE001 - pass - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py deleted file mode 100644 index cd08d047..00000000 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ /dev/null @@ -1,724 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: `cmux ssh` creates a remote-tagged workspace with remote metadata.""" - -from __future__ import annotations - -import base64 -import glob -import json -import os -import re -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli(cli: str, args: list[str], *, json_output: bool, extra_env: dict[str, str] | None = None) -> str: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - if extra_env: - env.update(extra_env) - - cmd = [cli, "--socket", SOCKET_PATH] - if json_output: - cmd.append("--json") - cmd.extend(args) - proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) - if proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") - return proc.stdout - - -def _run_cli_json(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> dict: - output = _run_cli(cli, args, json_output=True, extra_env=extra_env) - try: - return json.loads(output or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") - - -def _extract_control_path(ssh_command: str) -> str: - match = re.search(r"ControlPath=([^\s]+)", ssh_command) - return match.group(1) if match else "" - - -def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None: - deadline = time.time() + timeout - last_exc: Exception | None = None - while time.time() < deadline: - surfaces = client.list_surfaces(workspace_id) - for _, surface_id, _ in surfaces: - try: - return client.read_terminal_text(surface_id) - except cmuxError as exc: - text = str(exc).lower() - if "terminal surface not found" in text: - last_exc = exc - continue - raise - time.sleep(0.1) - print(f"WARN: readable terminal surface unavailable in workspace {workspace_id}; skipping transcript assertion ({last_exc})") - return None - - -def _resolve_workspace_id_from_payload(client: cmux, payload: dict) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - - workspace_ref = str(payload.get("workspace_ref") or "") - if not workspace_ref.startswith("workspace:"): - return "" - - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - return str(row.get("id") or "") - return "" - - -def _append_workspace_to_cleanup(workspaces_to_close: list[str], workspace_id: str) -> str: - if workspace_id: - workspaces_to_close.append(workspace_id) - return workspace_id - - -def _find_workspace_row(client: cmux, workspace_id: str) -> dict | None: - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("id") or "") == workspace_id: - return row - return None - - -def main() -> int: - cli = _find_cli_binary() - help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) - _must("cmux ssh" in help_text, "ssh --help output should include command header") - _must("Create a new workspace" in help_text, "ssh --help output should describe workspace creation") - - workspace_id = "" - workspace_id_without_name = "" - workspace_id_strict_override = "" - workspace_id_case_override = "" - workspace_id_invalid_proxy_port = "" - workspaces_to_close: list[str] = [] - ssh_workspace_name = "ssh-meta-test" - with cmux(SOCKET_PATH) as client: - try: - payload = _run_cli_json( - cli, - ["ssh", "127.0.0.1", "--port", "1", "--name", ssh_workspace_name], - ) - payload_workspace_id = _resolve_workspace_id_from_payload(client, payload) - selected_workspace_id = "" - listed_row = None - deadline_select = time.time() + 5.0 - while time.time() < deadline_select: - try: - selected_workspace_id = client.current_workspace() - except cmuxError: - time.sleep(0.05) - continue - listed_row = _find_workspace_row(client, selected_workspace_id) - remote = (listed_row or {}).get("remote") or {} - if not listed_row: - time.sleep(0.05) - continue - if str(listed_row.get("title") or "") != ssh_workspace_name: - time.sleep(0.05) - continue - if payload_workspace_id and selected_workspace_id != payload_workspace_id: - time.sleep(0.05) - continue - if bool(remote.get("enabled")) is True: - break - time.sleep(0.05) - _must( - bool(selected_workspace_id) - and listed_row is not None - and str(listed_row.get("title") or "") == ssh_workspace_name - and bool((listed_row.get("remote") or {}).get("enabled")) is True - and (not payload_workspace_id or selected_workspace_id == payload_workspace_id), - f"cmux ssh should select the new remote workspace: selected={selected_workspace_id} row={listed_row} payload={payload}", - ) - workspace_id = _append_workspace_to_cleanup( - workspaces_to_close, - payload_workspace_id or selected_workspace_id, - ) - remote_relay_port = payload.get("remote_relay_port") - _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") - remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}" - ssh_command = str(payload.get("ssh_command") or "") - _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") - _must( - ssh_command.startswith("ssh "), - f"cmux ssh should emit plain ssh command text (env is passed via workspace.create initial_env): {ssh_command!r}", - ) - ssh_terminal_command = str(payload.get("ssh_terminal_command") or "") - _must(bool(ssh_terminal_command), f"cmux ssh output missing ssh_terminal_command: {payload}") - _must( - ssh_terminal_command.startswith("ssh "), - f"cmux ssh should emit a terminal command that launches the remote bootstrap over ssh: {ssh_terminal_command!r}", - ) - ssh_startup_command = str(payload.get("ssh_startup_command") or "") - _must( - "cmux-ssh-startup-" in ssh_startup_command and ssh_startup_command.rstrip("'").endswith(".sh"), - f"cmux ssh should launch a generated startup script that preserves shell integration and cleanup: {ssh_startup_command!r}", - ) - _must(os.path.isfile(ssh_startup_command), f"cmux ssh startup script should exist on disk: {ssh_startup_command!r}") - ssh_startup_script = Path(ssh_startup_command).read_text() - _must( - f"cmux_bootstrap_path=\"$HOME/.cmux/relay/{int(remote_relay_port)}.bootstrap.sh\"" in ssh_startup_script, - f"cmux ssh startup script should stage the remote bootstrap payload under ~/.cmux/relay: {ssh_startup_command!r}", - ) - _must( - "cat > \"$cmux_bootstrap_path\"" in ssh_startup_script, - f"cmux ssh startup script should upload the remote bootstrap over stdin before opening the interactive ssh session: {ssh_startup_command!r}", - ) - _must( - "/bin/sh -c " in ssh_startup_script and "/bin/sh -lc " not in ssh_startup_script, - f"cmux ssh startup script should install the bootstrap through a non-login POSIX shell: {ssh_startup_command!r}", - ) - _must( - f"/bin/sh \"$HOME/.cmux/relay/{int(remote_relay_port)}.bootstrap.sh\"" in ssh_startup_script, - f"cmux ssh startup script should execute the staged remote bootstrap file through /bin/sh: {ssh_startup_command!r}", - ) - _must( - "export PROGRAMA_BOOTSTRAP_TTY=\"$cmux_bootstrap_tty\"" in ssh_startup_script, - f"cmux ssh startup script should preserve the bootstrap tty for relay warmup: {ssh_startup_command!r}", - ) - bootstrap_b64_match = re.search(r"^cmux_remote_bootstrap_b64=([A-Za-z0-9+/=]+)$", ssh_startup_script, re.MULTILINE) - _must( - bootstrap_b64_match is not None, - f"cmux ssh startup script should embed the remote bootstrap payload: {ssh_startup_command!r}", - ) - remote_bootstrap = base64.b64decode(str(bootstrap_b64_match.group(1))).decode("utf-8") - ssh_env_overrides = payload.get("ssh_env_overrides") or {} - _must( - str(ssh_env_overrides.get("GHOSTTY_SHELL_FEATURES") or "").endswith("ssh-env,ssh-terminfo"), - f"cmux ssh should pass shell niceties via ssh_env_overrides: {payload}", - ) - _must(not ssh_command.startswith("env "), f"ssh command should not include env prefix: {ssh_command!r}") - _must("-o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}") - _must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}") - _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") - _must("ControlPath=/tmp/programa-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") - _must( - "RemoteCommand=" not in ssh_command, - f"cmux ssh should keep the plain ssh_command separate from the terminal bootstrap wrapper: {ssh_command!r}", - ) - _must( - "cmux_tmp=$(mktemp " in ssh_terminal_command, - f"cmux ssh should stage a temp startup script through ssh_terminal_command: {ssh_terminal_command!r}", - ) - _must( - "export PROGRAMA_BOOTSTRAP_TTY=\"$cmux_bootstrap_tty\"" in ssh_terminal_command, - f"cmux ssh should capture the bootstrap tty before handing off to the temp startup script: {ssh_terminal_command!r}", - ) - _must( - f"\"$HOME/.cmux/relay/{int(remote_relay_port)}.tty\"" in ssh_terminal_command, - f"cmux ssh should persist the bootstrap tty beside the relay metadata: {ssh_terminal_command!r}", - ) - _must( - f"export PATH=\"$HOME/.cmux/bin:$PATH\"" in remote_bootstrap, - f"cmux ssh should still prepend the remote cmux wrapper path in the remote bootstrap: {remote_bootstrap!r}", - ) - _must( - f"export PROGRAMA_SOCKET_PATH=127.0.0.1:{int(remote_relay_port)}" in remote_bootstrap, - f"cmux ssh should still pin the relay socket path in the remote bootstrap: {remote_bootstrap!r}", - ) - _must( - "export PROGRAMA_WORKSPACE_ID='__PROGRAMA_WORKSPACE_ID__'" in remote_bootstrap, - f"cmux ssh should export the remote workspace id into the bootstrap shell: {remote_bootstrap!r}", - ) - _must( - "export PROGRAMA_TAB_ID='__PROGRAMA_WORKSPACE_ID__'" in remote_bootstrap, - f"cmux ssh should keep PROGRAMA_TAB_ID aligned with the workspace id for shell integration: {remote_bootstrap!r}", - ) - _must( - "export PROGRAMA_SURFACE_ID='__PROGRAMA_SURFACE_ID__'" in remote_bootstrap, - f"cmux ssh should export the remote surface id into the bootstrap shell: {remote_bootstrap!r}", - ) - _must( - "export PROGRAMA_PANEL_ID='__PROGRAMA_SURFACE_ID__'" in remote_bootstrap, - f"cmux ssh should keep PROGRAMA_PANEL_ID aligned with the surface id for shell integration: {remote_bootstrap!r}", - ) - _must( - "case \"${PROGRAMA_LOGIN_SHELL##*/}\" in" in remote_bootstrap, - f"cmux ssh should still branch on the user's login shell when possible: {remote_bootstrap!r}", - ) - _must( - "cat > \"$cmux_shell_dir/.zshrc\"" in remote_bootstrap, - f"cmux ssh should install a post-rc zsh wrapper so the remote cmux wrapper stays first on PATH: {remote_bootstrap!r}", - ) - _must( - '"$cmux_relay_cli" rpc surface.report_tty "$cmux_relay_report_tty"' in remote_bootstrap, - f"cmux ssh should synchronously report the relay TTY during bootstrap: {remote_bootstrap!r}", - ) - _must( - 'cmux_relay_tty="${PROGRAMA_BOOTSTRAP_TTY:-}"' in remote_bootstrap, - f"cmux ssh should reuse the bootstrap tty when warming the relay-backed shell integration: {remote_bootstrap!r}", - ) - _must( - '"$cmux_relay_cli" rpc surface.ports_kick "$cmux_relay_ports_kick"' in remote_bootstrap, - f"cmux ssh should trigger an immediate relay-backed port scan during bootstrap: {remote_bootstrap!r}", - ) - _must( - "exec \"$PROGRAMA_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i" in remote_bootstrap, - f"cmux ssh should still support bash login shells with a post-rc wrapper file: {remote_bootstrap!r}", - ) - _must( - "exec \"$PROGRAMA_LOGIN_SHELL\" -i" in remote_bootstrap, - f"cmux ssh should still hand off to the user's interactive login shell when possible: {remote_bootstrap!r}", - ) - - _must(listed_row is not None, f"workspace.list did not include {workspace_id}") - remote = listed_row.get("remote") or {} - _must(bool(remote.get("enabled")) is True, f"workspace should be marked remote-enabled: {listed_row}") - _must(str(remote.get("destination") or "") == "127.0.0.1", f"remote destination mismatch: {remote}") - _must(str(listed_row.get("title") or "") == "ssh-meta-test", f"workspace title mismatch: {listed_row}") - _must( - str(remote.get("state") or "") in {"connecting", "connected", "error", "disconnected"}, - f"unexpected remote state: {remote}", - ) - proxy = remote.get("proxy") or {} - _must( - str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"}, - f"remote payload should include proxy state metadata: {remote}", - ) - _must( - "ssh_options" not in remote, - f"workspace remote payload should not expose raw ssh_options: {remote}", - ) - _must( - "identity_file" not in remote, - f"workspace remote payload should not expose identity_file: {remote}", - ) - _must( - bool(remote.get("has_ssh_options")) is True, - f"workspace remote payload should indicate ssh options are configured: {remote}", - ) - # Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell. - terminal_text = _read_any_terminal_text(client, workspace_id) - if terminal_text is not None: - _must("ControlPersist=600" not in terminal_text, f"cmux ssh should not inject raw ssh command text: {terminal_text!r}") - _must("GHOSTTY_SHELL_FEATURES=" not in terminal_text, f"cmux ssh should not inject env assignment text: {terminal_text!r}") - _must("BASH_EXECUTION_STRING=set" not in terminal_text, f"cmux ssh should not print the remote shell environment dump on connect: {terminal_text!r}") - - status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - status_remote = status.get("remote") or {} - _must(bool(status_remote.get("enabled")) is True, f"workspace.remote.status should report enabled remote: {status}") - daemon = status_remote.get("daemon") or {} - _must( - str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"}, - f"workspace.remote.status should include daemon state metadata: {status_remote}", - ) - # Fail-fast regression: unreachable SSH target should not stay stuck connecting forever. - # Current main can either keep the failed remote config around long enough to expose - # a daemon bootstrap error, or drop back to a disconnected local workspace once the - # failed terminal session has fully torn down. - deadline_daemon = time.time() + 12.0 - last_status = status - saw_bootstrap_error = False - while time.time() < deadline_daemon: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - last_remote = last_status.get("remote") or {} - last_daemon = last_remote.get("daemon") or {} - if str(last_daemon.get("state") or "") == "error": - saw_bootstrap_error = True - break - if bool(last_remote.get("enabled")) is False and str(last_remote.get("state") or "") == "disconnected": - break - time.sleep(0.2) - else: - raise cmuxError(f"unreachable host should fail fast instead of hanging in connecting: {last_status}") - - last_remote = last_status.get("remote") or {} - last_daemon = last_remote.get("daemon") or {} - if saw_bootstrap_error: - detail = str(last_daemon.get("detail") or "") - _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") - _must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}") - else: - _must( - str(last_remote.get("state") or "") == "disconnected", - f"unreachable host should eventually disconnect if it clears remote config: {last_status}", - ) - _must( - str(last_daemon.get("state") or "") == "unavailable", - f"daemon state should reset when the failed remote session tears down: {last_status}", - ) - - # Lifecycle regression: disconnect with clear should reset remote/daemon metadata. - disconnected = client._call( - "workspace.remote.disconnect", - {"workspace_id": workspace_id, "clear": True}, - ) or {} - disconnected_remote = disconnected.get("remote") or {} - disconnected_daemon = disconnected_remote.get("daemon") or {} - _must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}") - _must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}") - _must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}") - try: - client._call("workspace.remote.reconnect", {"workspace_id": workspace_id}) - raise cmuxError("workspace.remote.reconnect should fail when remote config was cleared") - except cmuxError as exc: - text = str(exc).lower() - _must("invalid_state" in text, f"workspace.remote.reconnect missing invalid_state for cleared config: {exc}") - _must("not configured" in text, f"workspace.remote.reconnect should explain missing remote config: {exc}") - - # Regression: --name is optional. - payload2 = _run_cli_json( - cli, - ["ssh", "127.0.0.1", "--port", "1"], - ) - workspace_id_without_name = _append_workspace_to_cleanup( - workspaces_to_close, - _resolve_workspace_id_from_payload(client, payload2), - ) - ssh_command_without_name = str(payload2.get("ssh_command") or "") - - _must(bool(workspace_id_without_name), f"cmux ssh without --name should still create workspace: {payload2}") - _must( - "ControlPath=/tmp/programa-ssh-" in ssh_command_without_name, - f"cmux ssh without --name should still include control path defaults: {ssh_command_without_name!r}", - ) - _must( - _extract_control_path(ssh_command) != _extract_control_path(ssh_command_without_name), - f"distinct cmux ssh workspaces should get distinct control paths: {ssh_command!r} vs {ssh_command_without_name!r}", - ) - row2 = None - listed2 = client._call("workspace.list", {}) or {} - for row in listed2.get("workspaces") or []: - if str(row.get("id") or "") == workspace_id_without_name: - row2 = row - break - _must(row2 is not None, f"workspace created without --name missing from workspace.list: {workspace_id_without_name}") - _must(bool(str((row2 or {}).get("title") or "").strip()), f"workspace title should not be empty without --name: {row2}") - reconnected = client._call("workspace.remote.reconnect", {"workspace_id": workspace_id_without_name}) or {} - reconnected_remote = reconnected.get("remote") or {} - _must(bool(reconnected_remote.get("enabled")) is True, f"workspace.remote.reconnect should keep remote enabled: {reconnected}") - _must( - str(reconnected_remote.get("state") or "") in {"connecting", "connected", "error"}, - f"workspace.remote.reconnect should transition into an active state: {reconnected}", - ) - - payload_strict_override = _run_cli_json( - cli, - [ - "ssh", - "127.0.0.1", - "--port", - "1", - "--name", - "ssh-meta-strict-override", - "--ssh-option", - "StrictHostKeyChecking=no", - ], - ) - workspace_id_strict_override = _append_workspace_to_cleanup( - workspaces_to_close, - _resolve_workspace_id_from_payload(client, payload_strict_override), - ) - _must( - bool(workspace_id_strict_override), - f"cmux ssh with StrictHostKeyChecking override should create workspace: {payload_strict_override}", - ) - ssh_command_strict_override = str(payload_strict_override.get("ssh_command") or "") - _must( - "-o StrictHostKeyChecking=no" in ssh_command_strict_override, - f"ssh command should include user StrictHostKeyChecking override: {ssh_command_strict_override!r}", - ) - _must( - "-o StrictHostKeyChecking=accept-new" not in ssh_command_strict_override, - f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}", - ) - strict_override_remote = payload_strict_override.get("remote") or {} - _must( - "ssh_options" not in strict_override_remote, - f"workspace remote payload should not expose raw ssh_options: {strict_override_remote}", - ) - _must( - bool(strict_override_remote.get("has_ssh_options")) is True, - f"workspace remote payload should indicate ssh options are configured: {strict_override_remote}", - ) - - payload_case_override = _run_cli_json( - cli, - [ - "ssh", - "127.0.0.1", - "--port", - "1", - "--name", - "ssh-meta-case-override", - "--ssh-option", - "stricthostkeychecking=no", - "--ssh-option", - "controlmaster=no", - "--ssh-option", - "controlpersist=0", - "--ssh-option", - "controlpath=/tmp/programa-ssh-%C-custom", - ], - ) - workspace_id_case_override = _append_workspace_to_cleanup( - workspaces_to_close, - _resolve_workspace_id_from_payload(client, payload_case_override), - ) - _must( - bool(workspace_id_case_override), - f"cmux ssh with lowercase SSH option overrides should create workspace: {payload_case_override}", - ) - ssh_command_case_override = str(payload_case_override.get("ssh_command") or "") - ssh_command_case_override_lower = ssh_command_case_override.lower() - _must( - "-o stricthostkeychecking=no" in ssh_command_case_override_lower, - f"ssh command should preserve lowercase StrictHostKeyChecking override: {ssh_command_case_override!r}", - ) - _must( - "stricthostkeychecking=accept-new" not in ssh_command_case_override_lower, - f"ssh command should not force default StrictHostKeyChecking when lowercase override is supplied: {ssh_command_case_override!r}", - ) - _must( - "-o controlmaster=no" in ssh_command_case_override_lower, - f"ssh command should preserve lowercase ControlMaster override: {ssh_command_case_override!r}", - ) - _must( - "controlmaster=auto" not in ssh_command_case_override_lower, - f"ssh command should not force default ControlMaster when lowercase override is supplied: {ssh_command_case_override!r}", - ) - _must( - "-o controlpersist=0" in ssh_command_case_override_lower, - f"ssh command should preserve lowercase ControlPersist override: {ssh_command_case_override!r}", - ) - _must( - "controlpersist=600" not in ssh_command_case_override_lower, - f"ssh command should not force default ControlPersist when lowercase override is supplied: {ssh_command_case_override!r}", - ) - _must( - "controlpath=/tmp/programa-ssh-%c-custom" in ssh_command_case_override_lower, - f"ssh command should preserve lowercase ControlPath override value: {ssh_command_case_override!r}", - ) - _must( - ssh_command_case_override_lower.count("controlpath=") == 1, - f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}", - ) - case_override_remote = payload_case_override.get("remote") or {} - _must( - "ssh_options" not in case_override_remote, - f"workspace remote payload should not expose raw ssh_options: {case_override_remote}", - ) - _must( - bool(case_override_remote.get("has_ssh_options")) is True, - f"workspace remote payload should indicate ssh options are configured: {case_override_remote}", - ) - - payload3 = _run_cli_json( - cli, - ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-features"], - extra_env={"GHOSTTY_SHELL_FEATURES": "cursor,title"}, - ) - payload3_env = payload3.get("ssh_env_overrides") or {} - merged_features = str(payload3_env.get("GHOSTTY_SHELL_FEATURES") or "") - _must( - merged_features == "cursor,title,ssh-env,ssh-terminfo", - f"cmux ssh should merge existing shell features when present: {payload3!r}", - ) - workspace_id3 = _append_workspace_to_cleanup( - workspaces_to_close, - _resolve_workspace_id_from_payload(client, payload3), - ) - if workspace_id3: - try: - client.close_workspace(workspace_id3) - except Exception: - pass - - invalid_proxy_port_workspace = client._call("workspace.create", {}) or {} - workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "") - if workspace_id_invalid_proxy_port: - workspaces_to_close.append(workspace_id_invalid_proxy_port) - _must(bool(workspace_id_invalid_proxy_port), f"workspace.create missing workspace_id: {invalid_proxy_port_workspace}") - - configured_with_string_ports = client._call( - "workspace.remote.configure", - { - "workspace_id": workspace_id_invalid_proxy_port, - "destination": "127.0.0.1", - "port": "2222", - "local_proxy_port": "31338", - "auto_connect": False, - }, - ) or {} - configured_with_string_ports_remote = configured_with_string_ports.get("remote") or {} - _must( - int(configured_with_string_ports_remote.get("port") or 0) == 2222, - f"workspace.remote.configure should parse numeric string port values: {configured_with_string_ports}", - ) - _must( - int(configured_with_string_ports_remote.get("local_proxy_port") or 0) == 31338, - f"workspace.remote.configure should parse numeric string local_proxy_port values: {configured_with_string_ports}", - ) - - valid_local_proxy_port = 31337 - configured_with_local_proxy_port = client._call( - "workspace.remote.configure", - { - "workspace_id": workspace_id_invalid_proxy_port, - "destination": "127.0.0.1", - "port": 2222, - "local_proxy_port": valid_local_proxy_port, - "auto_connect": False, - }, - ) or {} - configured_remote = configured_with_local_proxy_port.get("remote") or {} - _must( - int(configured_remote.get("port") or 0) == 2222, - f"workspace.remote.configure should echo explicit port in remote payload: {configured_with_local_proxy_port}", - ) - _must( - int(configured_remote.get("local_proxy_port") or 0) == valid_local_proxy_port, - f"workspace.remote.configure should echo local_proxy_port in remote payload: {configured_with_local_proxy_port}", - ) - - configured_with_null_ports = client._call( - "workspace.remote.configure", - { - "workspace_id": workspace_id_invalid_proxy_port, - "destination": "127.0.0.1", - "port": None, - "local_proxy_port": None, - "auto_connect": False, - }, - ) or {} - configured_with_null_ports_remote = configured_with_null_ports.get("remote") or {} - _must( - configured_with_null_ports_remote.get("port") is None, - f"workspace.remote.configure should allow null to clear port: {configured_with_null_ports}", - ) - _must( - configured_with_null_ports_remote.get("local_proxy_port") is None, - f"workspace.remote.configure should allow null to clear local_proxy_port: {configured_with_null_ports}", - ) - status_after_null_ports = client._call( - "workspace.remote.status", - {"workspace_id": workspace_id_invalid_proxy_port}, - ) or {} - status_after_null_ports_remote = status_after_null_ports.get("remote") or {} - _must( - status_after_null_ports_remote.get("port") is None, - f"workspace.remote.status should reflect cleared port: {status_after_null_ports}", - ) - _must( - status_after_null_ports_remote.get("local_proxy_port") is None, - f"workspace.remote.status should reflect cleared local_proxy_port: {status_after_null_ports}", - ) - - for invalid_local_proxy_port in [0, 65536, "abc", True, 22.5]: - try: - client._call( - "workspace.remote.configure", - { - "workspace_id": workspace_id_invalid_proxy_port, - "destination": "127.0.0.1", - "local_proxy_port": invalid_local_proxy_port, - "auto_connect": False, - }, - ) - raise cmuxError( - f"workspace.remote.configure should reject local_proxy_port={invalid_local_proxy_port!r}" - ) - except cmuxError as exc: - text = str(exc) - lowered = text.lower() - _must( - "invalid_params" in lowered, - f"workspace.remote.configure should return invalid_params for local_proxy_port={invalid_local_proxy_port!r}: {exc}", - ) - _must( - "local_proxy_port must be 1-65535" in text, - f"workspace.remote.configure should include validation hint for local_proxy_port={invalid_local_proxy_port!r}: {exc}", - ) - - for invalid_port in [0, 65536, "abc", True, 22.5]: - try: - client._call( - "workspace.remote.configure", - { - "workspace_id": workspace_id_invalid_proxy_port, - "destination": "127.0.0.1", - "port": invalid_port, - "auto_connect": False, - }, - ) - raise cmuxError( - f"workspace.remote.configure should reject port={invalid_port!r}" - ) - except cmuxError as exc: - text = str(exc) - lowered = text.lower() - _must( - "invalid_params" in lowered, - f"workspace.remote.configure should return invalid_params for port={invalid_port!r}: {exc}", - ) - _must( - "port must be 1-65535" in text, - f"workspace.remote.configure should include validation hint for port={invalid_port!r}: {exc}", - ) - - try: - client.close_workspace(workspace_id_invalid_proxy_port) - except Exception: - pass - else: - workspace_id_invalid_proxy_port = "" - finally: - for workspace_id_to_close in dict.fromkeys(workspaces_to_close): - if not workspace_id_to_close: - continue - try: - client.close_workspace(workspace_id_to_close) - except Exception: - pass - - print("PASS: cmux ssh marks workspace as remote, exposes remote metadata, and does not require --name") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py deleted file mode 100644 index 46a166ac..00000000 --- a/tests_v2/test_ssh_remote_cli_relay.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding.""" - -from __future__ import annotations - -import glob -import json -import os -import secrets -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -# Keep the fixture's extra HTTP server below 1024 so there are no eligible -# (>1023) ports to auto-forward. This guards the "connecting forever" regression. -REMOTE_HTTP_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_HTTP_PORT", "81")) - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - # Ensure --socket is what drives the relay path during tests. - env.pop("PROGRAMA_SOCKET_PATH", None) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _parse_host_port(docker_port_output: str) -> int: - text = docker_port_output.strip() - if not text: - raise cmuxError("docker port output was empty") - last = text.split(":")[-1] - return int(last) - - -def _shell_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: - return _run( - [ - "ssh", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=5", - "-p", str(host_port), - "-i", str(key_path), - host, - f"sh -lc {_shell_single_quote(script)}", - ], - check=check, - ) - - -def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) - if probe.returncode == 0 and "ready" in probe.stdout: - return - time.sleep(0.5) - raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") - - -def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - state = str(remote.get("state") or "") - daemon_state = str(daemon.get("state") or "") - if state == "connected" and daemon_state == "ready": - return last_status - time.sleep(0.5) - raise cmuxError(f"Remote daemon did not become ready: {last_status}") - - -def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: - ping_result = _ssh_run( - host, host_port, key_path, - f"PROGRAMA_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", - check=False, - ) - _must( - ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), - f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", - ) - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - - cli = _find_cli_binary() - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" - workspace_id = "" - workspace_id_2 = "" - - try: - # Generate SSH key pair - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - # Build and start Docker container - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _run([ - "docker", "run", "-d", "--rm", - "--name", container_name, - "-e", f"AUTHORIZED_KEY={pubkey}", - "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", - "-p", "127.0.0.1::22", - image_tag, - ]) - - port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout - host_ssh_port = _parse_host_port(port_info) - host = "root@127.0.0.1" - _wait_for_ssh(host, host_ssh_port, key_path) - - with cmux(SOCKET_PATH) as client: - # Create SSH workspace (this sets up the reverse socket forward) - payload = _run_cli_json( - cli, - [ - "ssh", - host, - "--name", "docker-cli-relay", - "--port", str(host_ssh_port), - "--identity", str(key_path), - "--ssh-option", "UserKnownHostsFile=/dev/null", - "--ssh-option", "StrictHostKeyChecking=no", - ], - ) - workspace_id = str(payload.get("workspace_id") or "") - workspace_ref = str(payload.get("workspace_ref") or "") - if not workspace_id and workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - workspace_id = str(row.get("id") or "") - break - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") - remote_relay_port = payload.get("remote_relay_port") - _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") - remote_relay_port = int(remote_relay_port) - _must(1 <= remote_relay_port <= 65535, f"remote_relay_port should be a valid TCP port: {remote_relay_port}") - remote_socket_addr = f"127.0.0.1:{remote_relay_port}" - startup_cmd = str(payload.get("ssh_startup_command") or "") - _must( - 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, - f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", - ) - _must( - f"PROGRAMA_SOCKET_PATH={remote_socket_addr}" in startup_cmd, - f"ssh startup command should pin PROGRAMA_SOCKET_PATH to workspace relay: {startup_cmd!r}", - ) - workspace_window_id = payload.get("window_id") - current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} - current = client._call("workspace.current", current_params) or {} - current_workspace_id = str(current.get("workspace_id") or "") - _must( - current_workspace_id == workspace_id, - f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", - ) - - # Wait for daemon to be ready - first_status = _wait_for_remote_ready(client, workspace_id) - first_remote = first_status.get("remote") or {} - # Regression: should transition to connected even with no eligible - # (>1023, non-ephemeral) remote ports. - _must( - not (first_remote.get("detected_ports") or []), - f"expected no eligible detected ports in fixture: {first_status}", - ) - _must( - not (first_remote.get("forwarded_ports") or []), - f"expected no forwarded ports when none are eligible: {first_status}", - ) - - # Verify remote cmux wrapper + relay-specific daemon mapping were installed. - wrapper_check = None - wrapper_deadline = time.time() + 10.0 - while time.time() < wrapper_deadline: - wrapper_check = _ssh_run( - host, host_ssh_port, key_path, - f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && " - f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && " - "daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && " - "test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok", - check=False, - ) - if "wrapper-ok" in (wrapper_check.stdout or ""): - break - time.sleep(0.4) - _must( - wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""), - f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}", - ) - - # Start a second SSH workspace to the same destination and verify both - # relays remain healthy (regression: same-host workspaces killed each other). - payload_2 = _run_cli_json( - cli, - [ - "ssh", - host, - "--name", "docker-cli-relay-2", - "--port", str(host_ssh_port), - "--identity", str(key_path), - "--ssh-option", "UserKnownHostsFile=/dev/null", - "--ssh-option", "StrictHostKeyChecking=no", - ], - ) - workspace_id_2 = str(payload_2.get("workspace_id") or "") - workspace_ref_2 = str(payload_2.get("workspace_ref") or "") - if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): - listed_2 = client._call("workspace.list", {}) or {} - for row in listed_2.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref_2: - workspace_id_2 = str(row.get("id") or "") - break - _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") - - remote_relay_port_2 = payload_2.get("remote_relay_port") - _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") - remote_relay_port_2 = int(remote_relay_port_2) - _must(1 <= remote_relay_port_2 <= 65535, f"second remote_relay_port should be a valid TCP port: {remote_relay_port_2}") - _must( - remote_relay_port_2 != remote_relay_port, - f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", - ) - remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" - startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "") - _must( - f"PROGRAMA_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2, - f"second ssh startup command should pin PROGRAMA_SOCKET_PATH to second relay: {startup_cmd_2!r}", - ) - _ = _wait_for_remote_ready(client, workspace_id_2) - - stability_deadline = time.time() + 8.0 - while time.time() < stability_deadline: - _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") - _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") - time.sleep(0.5) - - # Test 1: cmux ping (v1) - _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") - - # Test 2: cmux list-workspaces --json (v2) - list_ws_result = _ssh_run( - host, host_ssh_port, key_path, - f"PROGRAMA_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces", - check=False, - ) - _must( - list_ws_result.returncode == 0, - f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}", - ) - try: - ws_data = json.loads(list_ws_result.stdout.strip()) - _must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}") - except json.JSONDecodeError: - raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}") - - # Test 3: cmux new-window (v1) - new_win_result = _ssh_run( - host, host_ssh_port, key_path, - f"PROGRAMA_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window", - check=False, - ) - _must( - new_win_result.returncode == 0, - f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}", - ) - - # Test 4: cmux rpc system.capabilities (v2 passthrough) - rpc_result = _ssh_run( - host, host_ssh_port, key_path, - f"PROGRAMA_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities", - check=False, - ) - _must( - rpc_result.returncode == 0, - f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}", - ) - try: - caps_data = json.loads(rpc_result.stdout.strip()) - _must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}") - except json.JSONDecodeError: - raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}") - - # Cleanup - try: - client.close_workspace(workspace_id) - except Exception: - pass - workspace_id = "" - if workspace_id_2: - try: - client.close_workspace(workspace_id_2) - except Exception: - pass - workspace_id_2 = "" - - print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") - return 0 - - finally: - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - if workspace_id_2: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id_2) - except Exception: - pass - - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_daemon_resize_stdio.py b/tests_v2/test_ssh_remote_daemon_resize_stdio.py deleted file mode 100644 index 6eda759f..00000000 --- a/tests_v2/test_ssh_remote_daemon_resize_stdio.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -"""Process-level integration: programad-remote stdio session resize coordinator.""" - -from __future__ import annotations - -import json -import select -import shutil -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmuxError - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _daemon_module_dir() -> Path: - return Path(__file__).resolve().parents[1] / "daemon" / "remote" - - -def _rpc( - proc: subprocess.Popen[str], - req_id: int, - method: str, - params: dict, - *, - timeout_s: float = 5.0, -) -> dict: - if proc.stdin is None or proc.stdout is None: - raise cmuxError("daemon subprocess stdio pipes are not available") - - payload = {"id": req_id, "method": method, "params": params} - proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") - proc.stdin.flush() - - deadline = time.time() + timeout_s - while time.time() < deadline: - wait_s = max(0.0, min(0.2, deadline - time.time())) - ready, _, _ = select.select([proc.stdout], [], [], wait_s) - if not ready: - continue - line = proc.stdout.readline() - if line == "": - stderr = "" - if proc.stderr is not None: - try: - stderr = proc.stderr.read().strip() - except Exception: - stderr = "" - raise cmuxError(f"programad-remote exited while waiting for {method} response: {stderr}") - try: - resp = json.loads(line) - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON response for {method}: {line!r} ({exc})") - _must(resp.get("id") == req_id, f"Response id mismatch for {method}: {resp}") - return resp - - raise cmuxError(f"Timed out waiting for programad-remote response: {method}") - - -def _as_int(value: object, field: str) -> int: - if isinstance(value, bool): - raise cmuxError(f"{field} should be numeric, got bool") - if isinstance(value, int): - return value - if isinstance(value, float): - if not value.is_integer(): - raise cmuxError(f"{field} should be an integer value, got float {value!r}") - return int(value) - raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}") - - -def _assert_effective(resp: dict, want_cols: int, want_rows: int, label: str) -> None: - _must(resp.get("ok") is True, f"{label} should return ok=true: {resp}") - result = resp.get("result") or {} - got_cols = _as_int(result.get("effective_cols"), "effective_cols") - got_rows = _as_int(result.get("effective_rows"), "effective_rows") - _must( - got_cols == want_cols and got_rows == want_rows, - f"{label} effective size mismatch: got {got_cols}x{got_rows}, want {want_cols}x{want_rows} ({resp})", - ) - - -def main() -> int: - if shutil.which("go") is None: - print("SKIP: go is not available") - return 0 - - daemon_dir = _daemon_module_dir() - _must(daemon_dir.is_dir(), f"Missing daemon module directory: {daemon_dir}") - - proc = subprocess.Popen( - ["go", "run", "./cmd/programad-remote", "serve", "--stdio"], - cwd=str(daemon_dir), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - ) - - try: - hello = _rpc(proc, 1, "hello", {}) - _must(hello.get("ok") is True, f"hello should return ok=true: {hello}") - capabilities = {str(item) for item in ((hello.get("result") or {}).get("capabilities") or [])} - _must("session.basic" in capabilities, f"hello missing session.basic capability: {hello}") - _must("session.resize.min" in capabilities, f"hello missing session.resize.min capability: {hello}") - - open_resp = _rpc(proc, 2, "session.open", {"session_id": "sess-e2e"}) - _assert_effective(open_resp, 0, 0, "session.open") - - attach_small = _rpc( - proc, - 3, - "session.attach", - {"session_id": "sess-e2e", "attachment_id": "a-small", "cols": 90, "rows": 30}, - ) - _assert_effective(attach_small, 90, 30, "session.attach(a-small)") - - attach_large = _rpc( - proc, - 4, - "session.attach", - {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 140, "rows": 50}, - ) - _assert_effective(attach_large, 90, 30, "session.attach(a-large)") - - resize_large = _rpc( - proc, - 5, - "session.resize", - {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 200, "rows": 80}, - ) - _assert_effective(resize_large, 90, 30, "session.resize(a-large)") - - detach_small = _rpc( - proc, - 6, - "session.detach", - {"session_id": "sess-e2e", "attachment_id": "a-small"}, - ) - _assert_effective(detach_small, 200, 80, "session.detach(a-small)") - - detach_large = _rpc( - proc, - 7, - "session.detach", - {"session_id": "sess-e2e", "attachment_id": "a-large"}, - ) - _assert_effective(detach_large, 200, 80, "session.detach(a-large)") - - reattach = _rpc( - proc, - 8, - "session.attach", - {"session_id": "sess-e2e", "attachment_id": "a-reconnect", "cols": 110, "rows": 40}, - ) - _assert_effective(reattach, 110, 40, "session.attach(a-reconnect)") - - status = _rpc(proc, 9, "session.status", {"session_id": "sess-e2e"}) - _assert_effective(status, 110, 40, "session.status") - attachments = (status.get("result") or {}).get("attachments") or [] - _must(len(attachments) == 1, f"session.status should report one active attachment after reattach: {status}") - - print("PASS: programad-remote stdio session.resize coordinator enforces smallest-screen-wins semantics") - return 0 - finally: - try: - if proc.stdin is not None: - proc.stdin.close() - except Exception: - pass - try: - proc.terminate() - proc.wait(timeout=2.0) - except Exception: - try: - proc.kill() - except Exception: - pass - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py deleted file mode 100644 index afeeba86..00000000 --- a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py +++ /dev/null @@ -1,258 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: remote daemon bootstrap must not depend on login-shell startup files.""" - -from __future__ import annotations - -import os -import secrets -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -DOCKER_SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_HOST", "127.0.0.1") -DOCKER_PUBLISH_ADDR = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _parse_host_port(docker_port_output: str) -> int: - text = docker_port_output.strip() - if not text: - raise cmuxError("docker port output was empty") - return int(text.split(":")[-1]) - - -def _shell_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: - return _run( - [ - "ssh", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "StrictHostKeyChecking=no", - "-o", - "ConnectTimeout=5", - "-p", - str(host_port), - "-i", - str(key_path), - host, - f"sh -lc {_shell_single_quote(script)}", - ], - check=check, - ) - - -def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) - if probe.returncode == 0 and "ready" in probe.stdout: - return - time.sleep(0.5) - raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") - - -def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float = 45.0) -> dict: - deadline = time.time() + timeout - last_status: dict = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - proxy = remote.get("proxy") or {} - if ( - str(remote.get("state") or "") == "connected" - and str(daemon.get("state") or "") == "ready" - and str(proxy.get("state") or "") == "ready" - ): - return last_status - time.sleep(0.5) - raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}") - - -def _heartbeat_count(status: dict) -> int: - remote = status.get("remote") or {} - heartbeat = remote.get("heartbeat") or {} - raw = heartbeat.get("count") - try: - return int(raw or 0) - except Exception: # noqa: BLE001 - return 0 - - -def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict: - deadline = time.time() + timeout - last_status: dict = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - if _heartbeat_count(last_status) >= minimum_count: - return last_status - time.sleep(0.5) - raise cmuxError( - f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}" - ) - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-bootstrap-nonlogin-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-bootstrap-nonlogin-{secrets.token_hex(4)}" - workspace_id = "" - - try: - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _run( - [ - "docker", - "run", - "-d", - "--rm", - "--name", - container_name, - "-e", - f"AUTHORIZED_KEY={pubkey}", - "-p", - f"{DOCKER_PUBLISH_ADDR}::22", - image_tag, - ] - ) - - port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout - host_ssh_port = _parse_host_port(port_info) - host = f"root@{DOCKER_SSH_HOST}" - _wait_for_ssh(host, host_ssh_port, key_path) - - # Regression fixture: a slow login profile that should not block non-interactive daemon bootstrap. - _ssh_run( - host, - host_ssh_port, - key_path, - """ -cat > "$HOME/.profile" <<'EOF' -sleep 15 -echo profile-sourced >&2 -EOF -chmod 0644 "$HOME/.profile" -""", - check=True, - ) - - with cmux(SOCKET_PATH) as client: - created = client._call("workspace.create", {"initial_command": "echo ssh-bootstrap-nonlogin"}) - workspace_id = str((created or {}).get("workspace_id") or "") - _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") - - configured = client._call( - "workspace.remote.configure", - { - "workspace_id": workspace_id, - "destination": host, - "port": host_ssh_port, - "identity_file": str(key_path), - "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], - "auto_connect": True, - }, - ) - _must(bool(configured), "workspace.remote.configure returned empty response") - - status = _wait_for_remote_connected(client, workspace_id, timeout=45.0) - remote = status.get("remote") or {} - detail = str(remote.get("detail") or "").lower() - _must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}") - - baseline_heartbeat = _heartbeat_count(status) - status = _wait_for_heartbeat_advance( - client, - workspace_id, - minimum_count=max(1, baseline_heartbeat + 1), - timeout=15.0, - ) - - opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {} - browser_surface_id = str(opened.get("surface_id") or "") - _must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}") - - after_open_heartbeat = _heartbeat_count(status) - status_after_blank_tab = _wait_for_heartbeat_advance( - client, - workspace_id, - minimum_count=after_open_heartbeat + 2, - timeout=20.0, - ) - remote_after_blank_tab = status_after_blank_tab.get("remote") or {} - _must( - str(remote_after_blank_tab.get("state") or "") == "connected", - f"remote should remain connected after blank browser open: {status_after_blank_tab}", - ) - heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {} - _must( - heartbeat_payload.get("last_seen_at") is not None, - f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}", - ) - - try: - client.close_workspace(workspace_id) - except Exception: - pass - workspace_id = "" - - print("PASS: remote daemon bootstrap remains healthy even when ~/.profile is slow") - return 0 - finally: - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py deleted file mode 100644 index b0377229..00000000 --- a/tests_v2/test_ssh_remote_docker_forwarding.py +++ /dev/null @@ -1,742 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: remote SSH proxy endpoint via `cmux ssh`.""" - -from __future__ import annotations - -import glob -import hashlib -import json -import os -import secrets -import shutil -import socket -import struct -import subprocess -import sys -import tempfile -import time -from base64 import b64encode -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -REMOTE_HTTP_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_HTTP_PORT", "43173")) -REMOTE_WS_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_WS_PORT", "43174")) -MAX_REMOTE_DAEMON_SIZE_BYTES = int(os.environ.get("PROGRAMA_SSH_TEST_MAX_DAEMON_SIZE_BYTES", "15000000")) -DOCKER_SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_HOST", "127.0.0.1") -DOCKER_PUBLISH_ADDR = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _parse_host_port(docker_port_output: str) -> int: - # docker port output form: "127.0.0.1:49154\n" or ":::\d+". - text = docker_port_output.strip() - if not text: - raise cmuxError("docker port output was empty") - last = text.split(":")[-1] - return int(last) - - -def _curl_via_socks(proxy_port: int, target_url: str) -> str: - if shutil.which("curl") is None: - raise cmuxError("curl is required for SOCKS proxy verification") - proc = _run( - [ - "curl", - "--silent", - "--show-error", - "--max-time", - "5", - "--socks5-hostname", - f"127.0.0.1:{proxy_port}", - target_url, - ], - check=False, - ) - if proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"curl via SOCKS proxy failed: {merged}") - return proc.stdout - - -def _shell_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def _recv_exact(sock: socket.socket, n: int) -> bytes: - out = bytearray() - while len(out) < n: - chunk = sock.recv(n - len(out)) - if not chunk: - raise cmuxError("unexpected EOF while reading socket") - out.extend(chunk) - return bytes(out) - - -def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: - out = bytearray() - while marker not in out: - chunk = sock.recv(1024) - if not chunk: - raise cmuxError("unexpected EOF while reading response headers") - out.extend(chunk) - if len(out) > limit: - raise cmuxError("response headers too large") - return bytes(out) - - -def _read_socks5_connect_reply(sock: socket.socket) -> None: - head = _recv_exact(sock, 4) - if len(head) != 4 or head[0] != 0x05: - raise cmuxError(f"invalid SOCKS5 reply: {head!r}") - if head[1] != 0x00: - raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") - - atyp = head[3] - if atyp == 0x01: - _ = _recv_exact(sock, 4) - elif atyp == 0x03: - ln = _recv_exact(sock, 1)[0] - _ = _recv_exact(sock, ln) - elif atyp == 0x04: - _ = _recv_exact(sock, 16) - else: - raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{atyp:02x}") - _ = _recv_exact(sock, 2) # bound port - - -def _read_http_response_from_connected_socket(sock: socket.socket) -> str: - response = _recv_until(sock, b"\r\n\r\n") - header_end = response.index(b"\r\n\r\n") + 4 - header_blob = response[:header_end] - body = bytearray(response[header_end:]) - header_text = header_blob.decode("utf-8", errors="replace") - - status_line = header_text.split("\r\n", 1)[0] - if "200" not in status_line: - raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") - - content_length: int | None = None - for line in header_text.split("\r\n")[1:]: - if line.lower().startswith("content-length:"): - try: - content_length = int(line.split(":", 1)[1].strip()) - except Exception: # noqa: BLE001 - content_length = None - break - - if content_length is not None: - while len(body) < content_length: - chunk = sock.recv(4096) - if not chunk: - break - body.extend(chunk) - else: - while True: - try: - chunk = sock.recv(4096) - except socket.timeout: - break - if not chunk: - break - body.extend(chunk) - - return bytes(body).decode("utf-8", errors="replace") - - -def _http_get_on_connected_socket(sock: socket.socket, host: str, port: int, path: str = "/") -> str: - request = ( - f"GET {path} HTTP/1.1\r\n" - f"Host: {host}:{port}\r\n" - "Connection: close\r\n" - "\r\n" - ).encode("utf-8") - sock.sendall(request) - return _read_http_response_from_connected_socket(sock) - - -def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: - sock = socket.create_connection((proxy_host, proxy_port), timeout=6) - sock.settimeout(6) - - # greeting: no-auth only - sock.sendall(b"\x05\x01\x00") - greeting = _recv_exact(sock, 2) - if greeting != b"\x05\x00": - sock.close() - raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") - - try: - host_bytes = socket.inet_aton(target_host) - atyp = b"\x01" # IPv4 - addr = host_bytes - except OSError: - host_encoded = target_host.encode("utf-8") - if len(host_encoded) > 255: - sock.close() - raise cmuxError("target host too long for SOCKS5 domain form") - atyp = b"\x03" # domain - addr = bytes([len(host_encoded)]) + host_encoded - - req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) - sock.sendall(req) - - try: - _read_socks5_connect_reply(sock) - except Exception: - sock.close() - raise - return sock - - -def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: - sock = socket.create_connection((proxy_host, proxy_port), timeout=6) - sock.settimeout(6) - try: - try: - host_bytes = socket.inet_aton(target_host) - atyp = b"\x01" - addr = host_bytes - except OSError: - host_encoded = target_host.encode("utf-8") - if len(host_encoded) > 255: - raise cmuxError("target host too long for SOCKS5 domain form") - atyp = b"\x03" - addr = bytes([len(host_encoded)]) + host_encoded - - greeting = b"\x05\x01\x00" - connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) - http_get = ( - "GET / HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - "Connection: close\r\n" - "\r\n" - ).encode("utf-8") - - # Send greeting + CONNECT + first upstream payload in one write to exercise - # SOCKS request parsing when pending bytes already exist in the handshake buffer. - sock.sendall(greeting + connect_req + http_get) - - greeting_reply = _recv_exact(sock, 2) - if greeting_reply != b"\x05\x00": - raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") - _read_socks5_connect_reply(sock) - return _read_http_response_from_connected_socket(sock) - finally: - try: - sock.close() - except Exception: - pass - - -def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: - sock = socket.create_connection((proxy_host, proxy_port), timeout=6) - sock.settimeout(6) - request = ( - f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - "Proxy-Connection: Keep-Alive\r\n" - "\r\n" - ).encode("utf-8") - sock.sendall(request) - header_blob = _recv_until(sock, b"\r\n\r\n") - header_text = header_blob.decode("utf-8", errors="replace") - status_line = header_text.split("\r\n", 1)[0] - if "200" not in status_line: - sock.close() - raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") - return sock - - -def _encode_client_text_frame(payload: str) -> bytes: - data = payload.encode("utf-8") - first = 0x81 # FIN + text - mask = secrets.token_bytes(4) - length = len(data) - if length < 126: - header = bytes([first, 0x80 | length]) - elif length <= 0xFFFF: - header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) - else: - header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) - masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) - return header + mask + masked - - -def _read_server_text_frame(sock: socket.socket) -> str: - first, second = _recv_exact(sock, 2) - opcode = first & 0x0F - masked = (second & 0x80) != 0 - length = second & 0x7F - if length == 126: - length = struct.unpack("!H", _recv_exact(sock, 2))[0] - elif length == 127: - length = struct.unpack("!Q", _recv_exact(sock, 8))[0] - mask = _recv_exact(sock, 4) if masked else b"" - payload = _recv_exact(sock, length) if length else b"" - if masked and payload: - payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) - - if opcode != 0x1: - raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") - try: - return payload.decode("utf-8") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") - - -def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: - ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") - request = ( - "GET /echo HTTP/1.1\r\n" - f"Host: {ws_host}:{ws_port}\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - f"Sec-WebSocket-Key: {ws_key}\r\n" - "Sec-WebSocket-Version: 13\r\n" - "\r\n" - ).encode("utf-8") - sock.sendall(request) - header_blob = _recv_until(sock, b"\r\n\r\n") - header_text = header_blob.decode("utf-8", errors="replace") - status_line = header_text.split("\r\n", 1)[0] - if "101" not in status_line: - raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") - - expected_accept = b64encode( - hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() - ).decode("ascii") - lowered_headers = { - line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() - for line in header_text.split("\r\n")[1:] - if ":" in line - } - if lowered_headers.get("sec-websocket-accept", "") != expected_accept: - raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") - - sock.sendall(_encode_client_text_frame(message)) - return _read_server_text_frame(sock) - - -def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: - sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) - try: - return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") - finally: - try: - sock.close() - except Exception: - pass - - -def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: - sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) - try: - return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") - finally: - try: - sock.close() - except Exception: - pass - - -def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: - return _run( - [ - "ssh", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "StrictHostKeyChecking=no", - "-o", - "ConnectTimeout=5", - "-p", - str(host_port), - "-i", - str(key_path), - host, - f"sh -lc {_shell_single_quote(script)}", - ], - check=check, - ) - - -def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) - if probe.returncode == 0 and "ready" in probe.stdout: - return - time.sleep(0.5) - raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") - - -def _remote_binary_size_bytes(host: str, host_port: int, key_path: Path, remote_path: str) -> int: - script = f""" -set -eu -p={_shell_single_quote(remote_path)} -case "$p" in - /*) full="$p" ;; - *) full="$HOME/$p" ;; -esac -test -x "$full" -wc -c < "$full" -""" - proc = _ssh_run(host, host_port, key_path, script, check=True) - text = proc.stdout.strip().splitlines()[-1].strip() - return int(text) - - -def _extract_daemon_version_platform(remote_path: str) -> tuple[str, str]: - parts = [segment for segment in remote_path.strip().split("/") if segment] - try: - marker_index = parts.index("programad-remote") - except ValueError as exc: - raise cmuxError(f"remote daemon path missing programad-remote marker: {remote_path!r}") from exc - - required_len = marker_index + 4 - _must( - len(parts) >= required_len, - f"remote daemon path should include version/platform/binary: {remote_path!r}", - ) - version = parts[marker_index + 1] - platform = parts[marker_index + 2] - binary_name = parts[marker_index + 3] - _must(binary_name == "programad-remote", f"unexpected daemon binary name in remote path: {remote_path!r}") - _must(bool(version), f"daemon version should not be empty in remote path: {remote_path!r}") - _must(bool(platform), f"daemon platform should not be empty in remote path: {remote_path!r}") - return version, platform - - -def _local_cached_daemon_binary(version: str, platform: str) -> Path: - return Path(tempfile.gettempdir()) / "cmux-remote-daemon-build" / version / platform / "programad-remote" - - -def _local_file_sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _local_binary_contains_version_marker(path: Path, version: str) -> bool: - marker = version.encode("utf-8") - tail = b"" - with path.open("rb") as handle: - while True: - chunk = handle.read(1024 * 1024) - if not chunk: - return False - haystack = tail + chunk - if marker in haystack: - return True - tail = haystack[-max(len(marker) - 1, 0) :] - - -def _remote_binary_sha256(host: str, host_port: int, key_path: Path, remote_path: str) -> str: - script = f""" -set -eu -p={_shell_single_quote(remote_path)} -case "$p" in - /*) full="$p" ;; - *) full="$HOME/$p" ;; -esac -test -x "$full" -if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$full" | awk '{{print $1}}' -elif command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$full" | awk '{{print $1}}' -else - openssl dgst -sha256 "$full" | awk '{{print $NF}}' -fi -""" - proc = _ssh_run(host, host_port, key_path, script, check=True) - digest = proc.stdout.strip().splitlines()[-1].strip().lower() - _must(len(digest) == 64 and all(ch in "0123456789abcdef" for ch in digest), f"invalid remote SHA256 digest: {digest!r}") - return digest - - -def _wait_connected_proxy_port(client: cmux, workspace_id: str, timeout: float = 45.0) -> tuple[dict, int]: - deadline = time.time() + timeout - last_status = {} - proxy_port: int | None = None - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - state = str(remote.get("state") or "") - proxy = remote.get("proxy") or {} - port_value = proxy.get("port") - if isinstance(port_value, int): - proxy_port = port_value - elif isinstance(port_value, str) and port_value.isdigit(): - proxy_port = int(port_value) - if state == "connected" and proxy_port is not None: - return last_status, proxy_port - time.sleep(0.5) - raise cmuxError(f"Remote proxy did not converge to connected state: {last_status}") - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - - cli = _find_cli_binary() - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-docker-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-test-{secrets.token_hex(4)}" - workspace_id = "" - workspace_id_shared = "" - - try: - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _run([ - "docker", "run", "-d", "--rm", - "--name", container_name, - "-e", f"AUTHORIZED_KEY={pubkey}", - "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", - "-p", f"{DOCKER_PUBLISH_ADDR}::22", - image_tag, - ]) - - port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout - host_ssh_port = _parse_host_port(port_info) - host = f"root@{DOCKER_SSH_HOST}" - _wait_for_ssh(host, host_ssh_port, key_path) - - fresh_check = _ssh_run( - host, - host_ssh_port, - key_path, - "test ! -e \"$HOME/.cmux/bin/programad-remote\" && echo fresh", - check=True, - ) - _must("fresh" in fresh_check.stdout, "Fresh container should not have preinstalled programad-remote") - - with cmux(SOCKET_PATH) as client: - payload = _run_cli_json( - cli, - [ - "ssh", - host, - "--name", "docker-ssh-forward", - "--port", str(host_ssh_port), - "--identity", str(key_path), - "--ssh-option", "UserKnownHostsFile=/dev/null", - "--ssh-option", "StrictHostKeyChecking=no", - ], - ) - workspace_id = str(payload.get("workspace_id") or "") - workspace_ref = str(payload.get("workspace_ref") or "") - if not workspace_id and workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - workspace_id = str(row.get("id") or "") - break - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") - - last_status, proxy_port = _wait_connected_proxy_port(client, workspace_id) - - daemon = ((last_status.get("remote") or {}).get("daemon") or {}) - _must(str(daemon.get("state") or "") == "ready", f"daemon should be ready in connected state: {last_status}") - capabilities = daemon.get("capabilities") or [] - _must("proxy.stream" in capabilities, f"daemon hello capabilities missing proxy.stream: {daemon}") - _must("proxy.socks5" in capabilities, f"daemon hello capabilities missing proxy.socks5: {daemon}") - _must("session.basic" in capabilities, f"daemon hello capabilities missing session.basic: {daemon}") - _must("session.resize.min" in capabilities, f"daemon hello capabilities missing session.resize.min: {daemon}") - remote_path = str(daemon.get("remote_path") or "").strip() - _must(bool(remote_path), f"daemon ready state should include remote_path: {daemon}") - - binary_size_bytes = _remote_binary_size_bytes(host, host_ssh_port, key_path, remote_path) - _must(binary_size_bytes > 0, f"uploaded daemon binary should be non-empty: {binary_size_bytes}") - _must( - binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES, - f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}", - ) - daemon_version, daemon_platform = _extract_daemon_version_platform(remote_path) - local_cached_binary = _local_cached_daemon_binary(daemon_version, daemon_platform) - _must( - local_cached_binary.is_file(), - f"expected local daemon cache artifact at {local_cached_binary} after bootstrap upload", - ) - _must( - os.access(local_cached_binary, os.X_OK), - f"local daemon cache artifact must be executable: {local_cached_binary}", - ) - _must( - _local_binary_contains_version_marker(local_cached_binary, daemon_version), - f"local cached daemon binary should embed daemon version marker {daemon_version!r}: {local_cached_binary}", - ) - local_sha256 = _local_file_sha256(local_cached_binary) - remote_sha256 = _remote_binary_sha256(host, host_ssh_port, key_path, remote_path) - _must( - local_sha256 == remote_sha256, - "uploaded daemon binary hash should match local cached build artifact " - f"(local={local_sha256}, remote={remote_sha256})", - ) - - body = "" - deadline_http = time.time() + 15.0 - while time.time() < deadline_http: - try: - body = _curl_via_socks(proxy_port, f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") - except Exception: - time.sleep(0.5) - continue - if "cmux-ssh-forward-ok" in body: - break - time.sleep(0.3) - - _must("cmux-ssh-forward-ok" in body, f"Forwarded HTTP endpoint returned unexpected body: {body[:120]!r}") - pipelined_body = _socks5_http_get_pipelined("127.0.0.1", proxy_port, "127.0.0.1", REMOTE_HTTP_PORT) - _must( - "cmux-ssh-forward-ok" in pipelined_body, - f"SOCKS pipelined greeting/connect+payload path returned unexpected body: {pipelined_body[:120]!r}", - ) - - ws_message = "cmux-ws-over-socks-ok" - echoed_message = _websocket_echo_via_socks(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_message) - _must( - echoed_message == ws_message, - f"WebSocket echo over SOCKS proxy mismatch: {echoed_message!r} != {ws_message!r}", - ) - - ws_connect_message = "cmux-ws-over-connect-ok" - echoed_connect = _websocket_echo_via_connect(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_connect_message) - _must( - echoed_connect == ws_connect_message, - f"WebSocket echo over CONNECT proxy mismatch: {echoed_connect!r} != {ws_connect_message!r}", - ) - - payload_shared = _run_cli_json( - cli, - [ - "ssh", - host, - "--name", "docker-ssh-forward-shared", - "--port", str(host_ssh_port), - "--identity", str(key_path), - "--ssh-option", "UserKnownHostsFile=/dev/null", - "--ssh-option", "StrictHostKeyChecking=no", - ], - ) - workspace_id_shared = str(payload_shared.get("workspace_id") or "") - workspace_ref_shared = str(payload_shared.get("workspace_ref") or "") - if not workspace_id_shared and workspace_ref_shared.startswith("workspace:"): - listed_shared = client._call("workspace.list", {}) or {} - for row in listed_shared.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref_shared: - workspace_id_shared = str(row.get("id") or "") - break - _must(bool(workspace_id_shared), f"cmux ssh output missing workspace_id for shared transport test: {payload_shared}") - - _, shared_proxy_port = _wait_connected_proxy_port(client, workspace_id_shared) - _must( - shared_proxy_port == proxy_port, - f"identical SSH transports should share one local proxy endpoint: {proxy_port} vs {shared_proxy_port}", - ) - - try: - client.close_workspace(workspace_id_shared) - workspace_id_shared = "" - except Exception: - pass - - try: - client.close_workspace(workspace_id) - workspace_id = "" - except Exception: - pass - - print( - "PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; " - f"uploaded programad-remote size={binary_size_bytes} bytes, version={daemon_version}, platform={daemon_platform}" - ) - return 0 - - finally: - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - - if workspace_id_shared: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id_shared) - except Exception: - pass - - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_reconnect.py b/tests_v2/test_ssh_remote_docker_reconnect.py deleted file mode 100644 index c1fb1c2d..00000000 --- a/tests_v2/test_ssh_remote_docker_reconnect.py +++ /dev/null @@ -1,762 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: remote SSH reconnect after host restart.""" - -from __future__ import annotations - -import glob -import hashlib -import json -import os -import secrets -import shutil -import socket -import struct -import subprocess -import sys -import tempfile -import time -from base64 import b64encode -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -REMOTE_HTTP_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_HTTP_PORT", "43173")) -REMOTE_WS_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_WS_PORT", "43174")) -DOCKER_SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_HOST", "127.0.0.1") -DOCKER_PUBLISH_ADDR = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _curl_via_socks(proxy_port: int, target_url: str) -> str: - if shutil.which("curl") is None: - raise cmuxError("curl is required for SOCKS proxy verification") - proc = _run( - [ - "curl", - "--silent", - "--show-error", - "--max-time", - "5", - "--socks5-hostname", - f"127.0.0.1:{proxy_port}", - target_url, - ], - check=False, - ) - if proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"curl via SOCKS proxy failed: {merged}") - return proc.stdout - - -def _find_free_loopback_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def _recv_exact(sock: socket.socket, n: int) -> bytes: - out = bytearray() - while len(out) < n: - chunk = sock.recv(n - len(out)) - if not chunk: - raise cmuxError("unexpected EOF while reading socket") - out.extend(chunk) - return bytes(out) - - -def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: - out = bytearray() - while marker not in out: - chunk = sock.recv(1024) - if not chunk: - raise cmuxError("unexpected EOF while reading response headers") - out.extend(chunk) - if len(out) > limit: - raise cmuxError("response headers too large") - return bytes(out) - - -def _read_socks5_connect_reply(sock: socket.socket) -> None: - head = _recv_exact(sock, 4) - if len(head) != 4 or head[0] != 0x05: - raise cmuxError(f"invalid SOCKS5 reply: {head!r}") - if head[1] != 0x00: - raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") - - reply_atyp = head[3] - if reply_atyp == 0x01: - _ = _recv_exact(sock, 4) - elif reply_atyp == 0x03: - ln = _recv_exact(sock, 1)[0] - _ = _recv_exact(sock, ln) - elif reply_atyp == 0x04: - _ = _recv_exact(sock, 16) - else: - raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{reply_atyp:02x}") - _ = _recv_exact(sock, 2) - - -def _read_http_response_from_connected_socket(sock: socket.socket) -> str: - response = _recv_until(sock, b"\r\n\r\n") - header_end = response.index(b"\r\n\r\n") + 4 - header_blob = response[:header_end] - body = bytearray(response[header_end:]) - header_text = header_blob.decode("utf-8", errors="replace") - - status_line = header_text.split("\r\n", 1)[0] - if "200" not in status_line: - raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") - - content_length: int | None = None - for line in header_text.split("\r\n")[1:]: - if line.lower().startswith("content-length:"): - try: - content_length = int(line.split(":", 1)[1].strip()) - except Exception: # noqa: BLE001 - content_length = None - break - - if content_length is not None: - while len(body) < content_length: - chunk = sock.recv(4096) - if not chunk: - break - body.extend(chunk) - else: - while True: - try: - chunk = sock.recv(4096) - except socket.timeout: - break - if not chunk: - break - body.extend(chunk) - - return bytes(body).decode("utf-8", errors="replace") - - -def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: - sock = socket.create_connection((proxy_host, proxy_port), timeout=6) - sock.settimeout(6) - - sock.sendall(b"\x05\x01\x00") - greeting = _recv_exact(sock, 2) - if greeting != b"\x05\x00": - sock.close() - raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") - - try: - host_bytes = socket.inet_aton(target_host) - atyp = b"\x01" - addr = host_bytes - except OSError: - host_encoded = target_host.encode("utf-8") - if len(host_encoded) > 255: - sock.close() - raise cmuxError("target host too long for SOCKS5 domain form") - atyp = b"\x03" - addr = bytes([len(host_encoded)]) + host_encoded - - req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) - sock.sendall(req) - - try: - _read_socks5_connect_reply(sock) - except Exception: - sock.close() - raise - return sock - - -def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: - sock = socket.create_connection((proxy_host, proxy_port), timeout=6) - sock.settimeout(6) - try: - try: - host_bytes = socket.inet_aton(target_host) - atyp = b"\x01" - addr = host_bytes - except OSError: - host_encoded = target_host.encode("utf-8") - if len(host_encoded) > 255: - raise cmuxError("target host too long for SOCKS5 domain form") - atyp = b"\x03" - addr = bytes([len(host_encoded)]) + host_encoded - - greeting = b"\x05\x01\x00" - connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) - http_get = ( - "GET / HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - "Connection: close\r\n" - "\r\n" - ).encode("utf-8") - - sock.sendall(greeting + connect_req + http_get) - - greeting_reply = _recv_exact(sock, 2) - if greeting_reply != b"\x05\x00": - raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") - _read_socks5_connect_reply(sock) - return _read_http_response_from_connected_socket(sock) - finally: - try: - sock.close() - except Exception: - pass - - -def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: - sock = socket.create_connection((proxy_host, proxy_port), timeout=6) - sock.settimeout(6) - request = ( - f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - "Proxy-Connection: Keep-Alive\r\n" - "\r\n" - ).encode("utf-8") - sock.sendall(request) - header_blob = _recv_until(sock, b"\r\n\r\n") - header_text = header_blob.decode("utf-8", errors="replace") - status_line = header_text.split("\r\n", 1)[0] - if "200" not in status_line: - sock.close() - raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") - return sock - - -def _encode_client_text_frame(payload: str) -> bytes: - data = payload.encode("utf-8") - first = 0x81 - mask = secrets.token_bytes(4) - length = len(data) - if length < 126: - header = bytes([first, 0x80 | length]) - elif length <= 0xFFFF: - header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) - else: - header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) - masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) - return header + mask + masked - - -def _read_server_text_frame(sock: socket.socket) -> str: - first, second = _recv_exact(sock, 2) - opcode = first & 0x0F - masked = (second & 0x80) != 0 - length = second & 0x7F - if length == 126: - length = struct.unpack("!H", _recv_exact(sock, 2))[0] - elif length == 127: - length = struct.unpack("!Q", _recv_exact(sock, 8))[0] - mask = _recv_exact(sock, 4) if masked else b"" - payload = _recv_exact(sock, length) if length else b"" - if masked and payload: - payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) - - if opcode != 0x1: - raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") - try: - return payload.decode("utf-8") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") - - -def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: - ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") - request = ( - "GET /echo HTTP/1.1\r\n" - f"Host: {ws_host}:{ws_port}\r\n" - "Upgrade: websocket\r\n" - "Connection: Upgrade\r\n" - f"Sec-WebSocket-Key: {ws_key}\r\n" - "Sec-WebSocket-Version: 13\r\n" - "\r\n" - ).encode("utf-8") - sock.sendall(request) - header_blob = _recv_until(sock, b"\r\n\r\n") - header_text = header_blob.decode("utf-8", errors="replace") - status_line = header_text.split("\r\n", 1)[0] - if "101" not in status_line: - raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") - - expected_accept = b64encode( - hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() - ).decode("ascii") - lowered_headers = { - line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() - for line in header_text.split("\r\n")[1:] - if ":" in line - } - if lowered_headers.get("sec-websocket-accept", "") != expected_accept: - raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") - - sock.sendall(_encode_client_text_frame(message)) - return _read_server_text_frame(sock) - - -def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: - sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) - try: - return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") - finally: - try: - sock.close() - except Exception: - pass - - -def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: - sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) - try: - return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") - finally: - try: - sock.close() - except Exception: - pass - - -def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None: - for _ in range(20): - proc = _run( - [ - "docker", - "run", - "-d", - "--rm", - "--name", - container_name, - "-e", - f"AUTHORIZED_KEY={pubkey}", - "-e", - f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", - "-e", - f"REMOTE_WS_PORT={REMOTE_WS_PORT}", - "-p", - f"{DOCKER_PUBLISH_ADDR}:{host_ssh_port}:22", - image_tag, - ], - check=False, - ) - if proc.returncode == 0: - return - time.sleep(0.5) - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}") - - -def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - proxy = remote.get("proxy") or {} - port_value = proxy.get("port") - proxy_port: int | None - if isinstance(port_value, int): - proxy_port = port_value - elif isinstance(port_value, str) and port_value.isdigit(): - proxy_port = int(port_value) - else: - proxy_port = None - if str(remote.get("state") or "") == "connected" and proxy_port is not None: - return last_status - time.sleep(0.5) - raise cmuxError(f"Remote did not reach connected+proxy-ready state: {last_status}") - - -def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - state = str(remote.get("state") or "") - if state in {"error", "connecting", "disconnected"}: - return last_status - time.sleep(0.5) - raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}") - - -def _browser_eval(client: cmux, surface_id: str, script: str) -> object: - payload = client._call("browser.eval", {"surface_id": surface_id, "script": script}) or {} - return payload.get("value") - - -def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout: float) -> None: - deadline = time.time() + timeout - last_text = "" - while time.time() < deadline: - try: - value = _browser_eval( - client, - surface_id, - "document.body ? (document.body.innerText || '') : ''", - ) - last_text = str(value or "") - except cmuxError: - time.sleep(0.2) - continue - if token in last_text: - return - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") - - -def _reload_remote_page_in_browser(client: cmux, surface_id: str, url: str, token: str) -> None: - client._call("browser.navigate", {"surface_id": surface_id, "url": "about:blank"}) - deadline = time.time() + 10.0 - last_url = "" - while time.time() < deadline: - try: - payload = client._call("browser.url.get", {"surface_id": surface_id}) or {} - last_url = str(payload.get("url") or "") - except cmuxError: - time.sleep(0.2) - continue - if last_url == "about:blank": - break - time.sleep(0.2) - _must(last_url == "about:blank", f"Browser did not clear its previous remote page before reconnect probe: {last_url!r}") - - client._call("browser.navigate", {"surface_id": surface_id, "url": url}) - _wait_browser_contains(client, surface_id, token, timeout=20.0) - - -def _websocket_echo_in_browser( - client: cmux, - surface_id: str, - ws_url: str, - message: str, - timeout: float = 15.0, -) -> str: - message_literal = json.dumps(message) - url_literal = json.dumps(ws_url) - initialized = _browser_eval( - client, - surface_id, - f""" - (() => {{ - if (window.__programaSSHReconnectSocket) {{ - window.__programaSSHReconnectSocket.close(); - }} - window.__programaSSHReconnectProbe = {{state: "connecting", echo: "", error: ""}}; - const socket = new WebSocket({url_literal}); - window.__programaSSHReconnectSocket = socket; - socket.onopen = () => socket.send({message_literal}); - socket.onmessage = (event) => {{ - window.__programaSSHReconnectProbe = {{state: "echoed", echo: String(event.data), error: ""}}; - socket.close(); - }}; - socket.onerror = () => {{ - window.__programaSSHReconnectProbe = {{state: "error", echo: "", error: "websocket error"}}; - }}; - socket.onclose = (event) => {{ - if (window.__programaSSHReconnectProbe.state === "connecting") {{ - window.__programaSSHReconnectProbe = {{ - state: "error", - echo: "", - error: `closed before echo (code=${{event.code}})`, - }}; - }} - }}; - return true; - }})() - """, - ) - _must(initialized is True, f"Failed to initialize WKWebView WebSocket probe: {initialized!r}") - - deadline = time.time() + timeout - last_probe: dict = {} - while time.time() < deadline: - try: - serialized = _browser_eval( - client, - surface_id, - "JSON.stringify(window.__programaSSHReconnectProbe || {})", - ) - decoded = json.loads(str(serialized or "{}")) - last_probe = decoded if isinstance(decoded, dict) else {} - except (cmuxError, json.JSONDecodeError): - time.sleep(0.2) - continue - if str(last_probe.get("state") or "") == "echoed": - return str(last_probe.get("echo") or "") - if str(last_probe.get("state") or "") == "error": - raise cmuxError(f"WKWebView WebSocket probe failed: {last_probe}") - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for WKWebView WebSocket echo: {last_probe}") - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - - cli = _find_cli_binary() - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}" - host_ssh_port = _find_free_loopback_port() - workspace_id = "" - browser_surface_id = "" - container_running = False - - try: - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _start_container(image_tag, container_name, pubkey, host_ssh_port) - container_running = True - - with cmux(SOCKET_PATH) as client: - payload = _run_cli_json( - cli, - [ - "ssh", - f"root@{DOCKER_SSH_HOST}", - "--name", - "docker-ssh-reconnect", - "--port", - str(host_ssh_port), - "--identity", - str(key_path), - "--ssh-option", - "UserKnownHostsFile=/dev/null", - "--ssh-option", - "StrictHostKeyChecking=no", - ], - ) - workspace_id = str(payload.get("workspace_id") or "") - workspace_ref = str(payload.get("workspace_ref") or "") - if not workspace_id and workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - workspace_id = str(row.get("id") or "") - break - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") - - first_status = _wait_remote_connected(client, workspace_id, timeout=45.0) - first_daemon = ((first_status.get("remote") or {}).get("daemon") or {}) - _must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}") - first_capabilities = {str(item) for item in (first_daemon.get("capabilities") or [])} - _must("proxy.stream" in first_capabilities, f"daemon should advertise proxy.stream: {first_status}") - _must("proxy.socks5" in first_capabilities, f"daemon should advertise proxy.socks5: {first_status}") - _must("proxy.http_connect" in first_capabilities, f"daemon should advertise proxy.http_connect: {first_status}") - first_proxy = ((first_status.get("remote") or {}).get("proxy") or {}) - first_proxy_port = first_proxy.get("port") - if isinstance(first_proxy_port, str) and first_proxy_port.isdigit(): - first_proxy_port = int(first_proxy_port) - _must(isinstance(first_proxy_port, int), f"connected status should include proxy port: {first_status}") - - first_body = "" - first_deadline_http = time.time() + 15.0 - while time.time() < first_deadline_http: - try: - first_body = _curl_via_socks(int(first_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") - except Exception: - time.sleep(0.5) - continue - if "cmux-ssh-forward-ok" in first_body: - break - time.sleep(0.3) - _must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}") - first_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(first_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) - _must( - "cmux-ssh-forward-ok" in first_pipelined_body, - f"SOCKS pipelined greeting/connect+payload failed before reconnect: {first_pipelined_body[:120]!r}", - ) - - first_ws_socks_message = "cmux-reconnect-before-over-socks" - echoed_before_socks = _websocket_echo_via_socks(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_socks_message) - _must( - echoed_before_socks == first_ws_socks_message, - f"WebSocket echo over SOCKS proxy failed before reconnect: {echoed_before_socks!r} != {first_ws_socks_message!r}", - ) - - first_ws_connect_message = "cmux-reconnect-before-over-connect" - echoed_before_connect = _websocket_echo_via_connect(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_connect_message) - _must( - echoed_before_connect == first_ws_connect_message, - f"WebSocket echo over CONNECT proxy failed before reconnect: {echoed_before_connect!r} != {first_ws_connect_message!r}", - ) - - remote_browser_url = f"http://127.0.0.1:{REMOTE_HTTP_PORT}/" - browser_payload = client._call( - "browser.open_split", - {"workspace_id": workspace_id, "url": remote_browser_url}, - ) or {} - browser_surface_id = str(browser_payload.get("surface_id") or "") - _must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {browser_payload}") - _wait_browser_contains(client, browser_surface_id, "cmux-ssh-forward-ok", timeout=20.0) - - first_ws_browser_message = "cmux-reconnect-before-in-wkwebview" - echoed_before_browser = _websocket_echo_in_browser( - client, - browser_surface_id, - f"ws://127.0.0.1:{REMOTE_WS_PORT}/echo", - first_ws_browser_message, - ) - _must( - echoed_before_browser == first_ws_browser_message, - f"WKWebView WebSocket echo failed before reconnect: {echoed_before_browser!r} != {first_ws_browser_message!r}", - ) - - _run(["docker", "rm", "-f", container_name], check=False) - container_running = False - _wait_remote_degraded(client, workspace_id, timeout=20.0) - - _start_container(image_tag, container_name, pubkey, host_ssh_port) - container_running = True - - second_status = _wait_remote_connected(client, workspace_id, timeout=60.0) - second_daemon = ((second_status.get("remote") or {}).get("daemon") or {}) - _must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}") - second_capabilities = {str(item) for item in (second_daemon.get("capabilities") or [])} - _must("proxy.stream" in second_capabilities, f"daemon should advertise proxy.stream after reconnect: {second_status}") - _must("proxy.socks5" in second_capabilities, f"daemon should advertise proxy.socks5 after reconnect: {second_status}") - _must("proxy.http_connect" in second_capabilities, f"daemon should advertise proxy.http_connect after reconnect: {second_status}") - second_proxy = ((second_status.get("remote") or {}).get("proxy") or {}) - second_proxy_port = second_proxy.get("port") - if isinstance(second_proxy_port, str) and second_proxy_port.isdigit(): - second_proxy_port = int(second_proxy_port) - _must(isinstance(second_proxy_port, int), f"reconnected status should include proxy port: {second_status}") - - second_body = "" - deadline_http = time.time() + 15.0 - while time.time() < deadline_http: - try: - second_body = _curl_via_socks(int(second_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") - except Exception: - time.sleep(0.5) - continue - if "cmux-ssh-forward-ok" in second_body: - break - time.sleep(0.3) - _must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}") - second_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(second_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) - _must( - "cmux-ssh-forward-ok" in second_pipelined_body, - f"SOCKS pipelined greeting/connect+payload failed after reconnect: {second_pipelined_body[:120]!r}", - ) - - second_ws_socks_message = "cmux-reconnect-after-over-socks" - echoed_after_socks = _websocket_echo_via_socks(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_socks_message) - _must( - echoed_after_socks == second_ws_socks_message, - f"WebSocket echo over SOCKS proxy failed after reconnect: {echoed_after_socks!r} != {second_ws_socks_message!r}", - ) - - second_ws_connect_message = "cmux-reconnect-after-over-connect" - echoed_after_connect = _websocket_echo_via_connect(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_connect_message) - _must( - echoed_after_connect == second_ws_connect_message, - f"WebSocket echo over CONNECT proxy failed after reconnect: {echoed_after_connect!r} != {second_ws_connect_message!r}", - ) - - _reload_remote_page_in_browser( - client, - browser_surface_id, - remote_browser_url, - "cmux-ssh-forward-ok", - ) - second_ws_browser_message = "cmux-reconnect-after-in-wkwebview" - echoed_after_browser = _websocket_echo_in_browser( - client, - browser_surface_id, - f"ws://127.0.0.1:{REMOTE_WS_PORT}/echo", - second_ws_browser_message, - ) - _must( - echoed_after_browser == second_ws_browser_message, - f"WKWebView WebSocket echo failed after reconnect: {echoed_after_browser!r} != {second_ws_browser_message!r}", - ) - - try: - client.close_workspace(workspace_id) - except Exception: - pass - workspace_id = "" - - print("PASS: docker SSH remote reconnects and re-establishes HTTP + WebSocket egress through broker and WKWebView") - return 0 - - finally: - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - - if container_running: - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py deleted file mode 100644 index 4f27a84d..00000000 --- a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: interactive `cmux ssh` shells must resolve `cmux` to the relay wrapper.""" - -from __future__ import annotations - -import glob -import json -import os -import re -import secrets -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - import subprocess - - proc = subprocess.run( - [cli, "--socket", SOCKET_PATH, "--json", *args], - capture_output=True, - text=True, - check=False, - env=env, - ) - if proc.returncode != 0: - raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _workspace_id_from_payload(client: cmux, payload: dict) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] - for row in rows: - if str(row.get("ref") or "") == workspace_ref: - return str(row.get("id") or "") - return "" - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 25.0) -> None: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return - time.sleep(0.25) - raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") - - -def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: - deadline = time.time() + timeout - while time.time() < deadline: - surfaces = client.list_surfaces(workspace_id) - if surfaces: - return str(surfaces[0][1]) - time.sleep(0.1) - raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") - - -def _wait_text(client: cmux, surface_id: str, token: str, timeout: float = 12.0) -> str: - deadline = time.time() + timeout - last = "" - while time.time() < deadline: - last = client.read_terminal_text(surface_id) - if token in last: - return last - time.sleep(0.15) - raise cmuxError(f"Timed out waiting for {token!r} in surface {surface_id}: {last[-1200:]!r}") - - -def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> None: - token = f"__PROGRAMA_SHELL_READY_{secrets.token_hex(6)}__" - client.send_surface(surface_id, f"printf '{token}'; echo") - client.send_key_surface(surface_id, "enter") - _wait_text(client, surface_id, token, timeout=timeout) - - -def _assert_no_login_profile_noise(text: str) -> None: - _must( - "/Users/cmux/.profile:" not in text, - f"interactive ssh shell should not source ~/.profile via the bootstrap wrapper: {text[-1200:]!r}", - ) - _must( - "No such file or directory" not in text, - f"interactive ssh shell still emitted startup file noise: {text[-1200:]!r}", - ) - - -def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]: - token = f"__PROGRAMA_REMOTE_CMD_{secrets.token_hex(6)}__" - start_marker = f"{token}:START" - status_marker = f"{token}:STATUS" - end_marker = f"{token}:END" - client.send_surface( - surface_id, - ( - f"printf '{start_marker}'; echo; " - f"{command}; " - "__cmux_status=$?; " - f"printf '{status_marker}:%s' \"$__cmux_status\"; echo; " - f"printf '{end_marker}'; echo" - ), - ) - client.send_key_surface(surface_id, "enter") - deadline = time.time() + timeout - text = "" - while time.time() < deadline: - text = client.read_terminal_text(surface_id) - if ( - text.count(start_marker) >= 2 - and text.count(status_marker) >= 2 - and text.count(end_marker) >= 2 - ): - break - time.sleep(0.15) - pattern = re.compile( - re.escape(start_marker) + r"\n(.*?)" + re.escape(status_marker) + r":(\d+)\n" + re.escape(end_marker), - re.S, - ) - matches = pattern.findall(text) - if not matches: - raise cmuxError(f"Missing command result token for {command!r}: {text[-1200:]!r}") - output, status_raw = matches[-1] - return int(status_raw), output, text - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run interactive ssh cmux command regression") - return 0 - - cli = _find_cli_binary() - workspace_ids: list[str] = [] - try: - with cmux(SOCKET_PATH) as client: - payload = _run_cli_json(cli, ["ssh", SSH_HOST]) - workspace_id = _workspace_id_from_payload(client, payload) - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") - workspace_ids.append(workspace_id) - - _wait_remote_ready(client, workspace_id) - surface_id = _wait_surface_id(client, workspace_id) - initial_text = client.read_terminal_text(surface_id) - _assert_no_login_profile_noise(initial_text) - _wait_shell_ready(client, surface_id) - shell_ready_text = client.read_terminal_text(surface_id) - _assert_no_login_profile_noise(shell_ready_text) - - which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux") - _must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}") - _must( - "/.cmux/bin/cmux" in which_output, - f"interactive ssh shell should resolve cmux to relay wrapper, got {which_output!r}", - ) - - ping_status, ping_output, ping_text = _run_remote_shell_command(client, surface_id, "cmux ping") - _must(ping_status == 0, f"`cmux ping` failed in interactive shell: output={ping_output!r} tail={ping_text[-1200:]!r}") - _must("pong" in ping_output.lower(), f"`cmux ping` should return pong, got {ping_output!r}") - _must( - "Socket not found at 127.0.0.1:" not in ping_text, - f"interactive ssh shell still routed cmux to a unix-socket-only binary: {ping_text[-1200:]!r}", - ) - _must( - "waiting for relay on 127.0.0.1:" not in ping_text and "failed to connect to 127.0.0.1:" not in ping_text, - f"`cmux ping` hit a dead ssh relay instead of the local app socket: {ping_text[-1200:]!r}", - ) - - notify_status, notify_output, notify_text = _run_remote_shell_command( - client, - surface_id, - "cmux notify --body interactive-ssh-regression", - ) - _must( - notify_status == 0, - f"`cmux notify` failed in interactive shell: output={notify_output!r} tail={notify_text[-1200:]!r}", - ) - _must( - "Socket not found at 127.0.0.1:" not in notify_text, - f"`cmux notify` still failed via wrong cmux binary: {notify_text[-1200:]!r}", - ) - _must( - "waiting for relay on 127.0.0.1:" not in notify_text and "failed to connect to 127.0.0.1:" not in notify_text, - f"`cmux notify` still failed because the ssh relay listener was not running: {notify_text[-1200:]!r}", - ) - - shell_status, shell_output, shell_text = _run_remote_shell_command( - client, - surface_id, - r'''printf 'TERM=%s\n' "${TERM:-}"; printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM:-}"; printf 'TERM_PROGRAM_VERSION=%s\n' "${TERM_PROGRAM_VERSION:-}"; printf 'GHOSTTY_SHELL_FEATURES=%s\n' "${GHOSTTY_SHELL_FEATURES:-}"; bindkey "^A"; bindkey "^K"; bindkey "^[^?"; bindkey "^[b"; bindkey "^[f"''', - ) - _must(shell_status == 0, f"ssh shell env/bindkey probe failed: output={shell_output!r} tail={shell_text[-1200:]!r}") - _must("TERM=xterm-ghostty" in shell_output, f"ssh shell lost TERM=xterm-ghostty: {shell_output!r}") - _must("TERM_PROGRAM=ghostty" in shell_output, f"ssh shell lost TERM_PROGRAM=ghostty: {shell_output!r}") - _must("GHOSTTY_SHELL_FEATURES=" in shell_output, f"ssh shell lost GHOSTTY_SHELL_FEATURES: {shell_output!r}") - _must("ssh-env" in shell_output, f"ssh shell missing ssh-env feature: {shell_output!r}") - _must("ssh-terminfo" in shell_output, f"ssh shell missing ssh-terminfo feature: {shell_output!r}") - _must('"^A" beginning-of-line' in shell_output, f"Ctrl-A binding regressed in ssh shell: {shell_output!r}") - _must('"^K" kill-line' in shell_output, f"Ctrl-K binding regressed in ssh shell: {shell_output!r}") - _must('"^[^?" backward-kill-word' in shell_output, f"Opt-Backspace binding regressed in ssh shell: {shell_output!r}") - _must('"^[b" backward-word' in shell_output, f"Opt-Left binding regressed in ssh shell: {shell_output!r}") - _must('"^[f" forward-word' in shell_output, f"Opt-Right binding regressed in ssh shell: {shell_output!r}") - finally: - if workspace_ids: - try: - with cmux(SOCKET_PATH) as client: - for workspace_id in workspace_ids: - try: - client._call("workspace.close", {"workspace_id": workspace_id}) - except Exception: - pass - except Exception: - pass - - print("PASS: interactive ssh shell resolves cmux to relay wrapper and remote cmux commands succeed") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py deleted file mode 100644 index 150c72eb..00000000 --- a/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: closing the last SSH surface should clear remote workspace state.""" - -from __future__ import annotations - -import glob -import json -import os -import re -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() -SSH_PORT = os.environ.get("PROGRAMA_SSH_TEST_PORT", "").strip() -SSH_IDENTITY = os.environ.get("PROGRAMA_SSH_TEST_IDENTITY", "").strip() -SSH_OPTIONS_RAW = os.environ.get("PROGRAMA_SSH_TEST_OPTIONS", "").strip() - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: - deadline = time.time() + timeout_s - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return - time.sleep(0.25) - raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") - - -def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - resolved = str(row.get("id") or "") - if resolved: - return resolved - - current = {wid for _index, wid, _title, _focused in client.list_workspaces()} - new_ids = sorted(current - before_workspace_ids) - if len(new_ids) == 1: - return new_ids[0] - - raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") - - -def _workspace_row(client: cmux, workspace_id: str) -> dict: - rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] - for row in rows: - if str(row.get("id") or "") == workspace_id: - return row - raise cmuxError(f"workspace.list missing {workspace_id}: {rows}") - - -def _remote_session_count(client: cmux, workspace_id: str) -> int: - row = _workspace_row(client, workspace_id) - remote = row.get("remote") or {} - return int(remote.get("active_terminal_sessions") or 0) - - -def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 12.0) -> str: - token = f"__PROGRAMA_{token_prefix}_{int(time.time() * 1000)}__" - client.send_surface( - surface_id, - ( - f"printf '{token}:START'; echo; " - f"{command}; " - f"printf '{token}:END'; echo" - ), - ) - client.send_key_surface(surface_id, "enter") - deadline = time.time() + timeout_s - last = "" - pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S) - while time.time() < deadline: - last = client.read_terminal_text(surface_id) - matches = pattern.findall(last) - if matches: - return matches[-1] - time.sleep(0.15) - raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}") - - -def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: - before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} - - ssh_args = ["ssh", SSH_HOST, "--name", name] - if SSH_PORT: - ssh_args.extend(["--port", SSH_PORT]) - if SSH_IDENTITY: - ssh_args.extend(["--identity", SSH_IDENTITY]) - if SSH_OPTIONS_RAW: - for option in SSH_OPTIONS_RAW.split(","): - trimmed = option.strip() - if trimmed: - ssh_args.extend(["--ssh-option", trimmed]) - - payload = _run_cli_json(cli, ssh_args) - workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) - _wait_remote_ready(client, workspace_id) - client.select_workspace(workspace_id) - _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) - return workspace_id - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run ssh last-surface remote state regression") - return 0 - - cli = _find_cli_binary() - workspace_id = "" - - try: - with cmux(SOCKET_PATH) as client: - workspace_id = _open_ssh_workspace( - client, - cli, - name=f"ssh-last-surface-{int(time.time())}", - ) - - row = _workspace_row(client, workspace_id) - remote = row.get("remote") or {} - _must(bool(remote.get("enabled")) is True, f"workspace should start as remote-enabled: {row}") - _must(int(remote.get("active_terminal_sessions") or 0) == 1, f"workspace should start with one active ssh terminal session: {row}") - - surfaces = client.list_surfaces(workspace_id) - _must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}") - - split_surface_id = client.new_split("right") - _wait_for(lambda: len(client.list_surfaces(workspace_id)) == 2, timeout_s=10.0, step_s=0.1) - _wait_for(lambda: _remote_session_count(client, workspace_id) == 2, timeout_s=10.0, step_s=0.1) - - client.send_surface(split_surface_id, "exit") - client.send_key_surface(split_surface_id, "enter") - _wait_for(lambda: _remote_session_count(client, workspace_id) == 1, timeout_s=15.0, step_s=0.15) - - row_after_first_exit = _workspace_row(client, workspace_id) - remote_after_first_exit = row_after_first_exit.get("remote") or {} - _must(bool(remote_after_first_exit.get("enabled")) is True, f"workspace should stay remote while one ssh terminal remains: {row_after_first_exit}") - - remaining_surface_id = next( - surface_id - for _index, surface_id, _focused in client.list_surfaces(workspace_id) - if surface_id != split_surface_id - ) - client.send_surface(remaining_surface_id, "exit") - client.send_key_surface(remaining_surface_id, "enter") - - def _remote_cleared() -> bool: - row_now = _workspace_row(client, workspace_id) - remote_now = row_now.get("remote") or {} - if bool(remote_now.get("enabled")): - return False - surfaces_now = client.list_surfaces(workspace_id) - return len(surfaces_now) == 2 - - _wait_for(_remote_cleared, timeout_s=15.0, step_s=0.15) - - final_row = _workspace_row(client, workspace_id) - final_remote = final_row.get("remote") or {} - _must(bool(final_remote.get("enabled")) is False, f"workspace remote metadata should clear after last ssh surface closes: {final_row}") - _must(str(final_remote.get("state") or "") == "disconnected", f"workspace should end disconnected after remote metadata clears: {final_row}") - _must(int(final_remote.get("active_terminal_sessions") or 0) == 0, f"workspace should report zero active ssh terminal sessions after last ssh surface closes: {final_row}") - - local_surface_ids = [surface_id for _index, surface_id, _focused in client.list_surfaces(workspace_id)] - _must(len(local_surface_ids) == 2, f"expected both panes to remain as local terminals after ssh exits, got {local_surface_ids}") - for idx, surface_id in enumerate(local_surface_ids): - socket_output = _run_surface_probe( - client, - surface_id, - r'''printf '%s' "${PROGRAMA_SOCKET_PATH:-}"''', - f"SSH_LAST_SURFACE_SOCKET_{idx}", - ).strip() - _must( - not socket_output.startswith("127.0.0.1:"), - f"surface {surface_id} should be local after clearing remote state, got PROGRAMA_SOCKET_PATH={socket_output!r}", - ) - finally: - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client._call("workspace.close", {"workspace_id": workspace_id}) - except Exception: - pass - - print("PASS: exiting all ssh panes clears remote workspace state while fallback local panes remain local") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_port_detection.py b/tests_v2/test_ssh_remote_port_detection.py deleted file mode 100644 index 7bdde2ec..00000000 --- a/tests_v2/test_ssh_remote_port_detection.py +++ /dev/null @@ -1,464 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: remote SSH workspaces detect listening ports from the live shell.""" - -from __future__ import annotations - -import glob -import json -import os -import pty -import re -import secrets -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -DOCKER_SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_HOST", "127.0.0.1") -DOCKER_PUBLISH_ADDR = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") -REMOTE_HTTP_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_HTTP_PORT", "8000")) -ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_SOCKET_PATH", None) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") from exc - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _parse_host_port(docker_port_output: str) -> int: - text = docker_port_output.strip() - if not text: - raise cmuxError("docker port output was empty") - return int(text.split(":")[-1]) - - -def _shell_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: - return _run( - [ - "ssh", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "StrictHostKeyChecking=no", - "-o", - "ConnectTimeout=5", - "-p", - str(host_port), - "-i", - str(key_path), - host, - f"sh -lc {_shell_single_quote(script)}", - ], - check=check, - ) - - -def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) - if probe.returncode == 0 and "ready" in probe.stdout: - return - time.sleep(0.5) - raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 45.0) -> dict: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return last_status - time.sleep(0.5) - raise cmuxError(f"Remote did not reach connected+ready state: {last_status}") - - -def _is_terminal_surface_not_found(exc: Exception) -> bool: - return "terminal surface not found" in str(exc).lower() - - -def _clean_text(raw: str) -> str: - text = OSC_ESCAPE_RE.sub("", raw) - text = ANSI_ESCAPE_RE.sub("", text) - return text.replace("\r", "") - - -def _wait_surface_contains( - client: cmux, - workspace_id: str, - surface_id: str, - token: str, - *, - timeout: float = 20.0, -) -> None: - deadline = time.time() + timeout - saw_missing_surface = False - while time.time() < deadline: - try: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - text = _clean_text(str(payload.get("text") or "")) - if token in text: - return - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - saw_missing_surface = True - time.sleep(0.2) - continue - raise - time.sleep(0.2) - - if saw_missing_surface: - raise cmuxError("terminal surface not found") - raise cmuxError(f"Timed out waiting for terminal token: {token}") - - -def _workspace_row(client: cmux, workspace_id: str) -> dict: - payload = client._call("workspace.list", {}) or {} - for row in payload.get("workspaces") or []: - if str(row.get("id") or "") == workspace_id: - return row - raise cmuxError(f"workspace {workspace_id} missing from workspace.list payload: {payload}") - - -def _debug_terminal_row(client: cmux, workspace_id: str, surface_id: str) -> dict: - payload = client._call("debug.terminals", {}) or {} - for row in payload.get("terminals") or []: - if str(row.get("workspace_id") or "") == workspace_id and str(row.get("surface_id") or "") == surface_id: - return row - raise cmuxError( - f"debug.terminals missing workspace={workspace_id!r} surface={surface_id!r}: {payload}" - ) - - -def _wait_surface_tty(client: cmux, workspace_id: str, surface_id: str, timeout: float = 20.0) -> str: - deadline = time.time() + timeout - last_row = {} - last_error: Exception | None = None - while time.time() < deadline: - try: - last_row = _debug_terminal_row(client, workspace_id, surface_id) - except cmuxError as exc: - last_error = exc - time.sleep(0.2) - continue - tty_name = str(last_row.get("tty") or "").strip() - if tty_name: - return tty_name - time.sleep(0.2) - if last_error is not None: - raise cmuxError(f"Timed out waiting for surface tty after terminal lookup retries: {last_error}") - raise cmuxError(f"Timed out waiting for surface tty: {last_row}") - - -def _launch_startup_command_pty(startup_command: str, workspace_id: str, surface_id: str) -> tuple[subprocess.Popen[bytes], int]: - _must(bool(startup_command.strip()), "cmux ssh output missing ssh_terminal_startup_command for PTY fallback") - env = dict(os.environ) - env.pop("PROGRAMA_SOCKET_PATH", None) - env["PROGRAMA_WORKSPACE_ID"] = workspace_id - env["PROGRAMA_SURFACE_ID"] = surface_id - env["PROGRAMA_TAB_ID"] = workspace_id - env["PROGRAMA_PANEL_ID"] = surface_id - - master_fd, slave_fd = pty.openpty() - try: - proc = subprocess.Popen( - ["/bin/sh", "-lc", startup_command], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - env=env, - start_new_session=True, - ) - except Exception: - os.close(slave_fd) - os.close(master_fd) - raise - os.close(slave_fd) - return proc, master_fd - - -def _wait_for_remote_port(client: cmux, workspace_id: str, port: int, timeout: float = 15.0) -> tuple[dict, dict]: - deadline = time.time() + timeout - last_status = {} - last_row = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - detected_ports = { - int(value) - for value in (remote.get("detected_ports") or []) - if str(value).isdigit() - } - - last_row = _workspace_row(client, workspace_id) - listening_ports = { - int(value) - for value in (last_row.get("listening_ports") or []) - if str(value).isdigit() - } - - if port in detected_ports and port in listening_ports: - return last_status, last_row - time.sleep(0.4) - - raise cmuxError( - "Remote listening port did not surface in remote status + workspace list: " - f"status={last_status} workspace={last_row}" - ) - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - - cli = _find_cli_binary() - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-port-detection-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-port-detect-{secrets.token_hex(4)}" - workspace_id = "" - surface_id = "" - pty_proc: subprocess.Popen[bytes] | None = None - pty_master_fd: int | None = None - - try: - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _run([ - "docker", - "run", - "-d", - "--rm", - "--name", - container_name, - "-e", - f"AUTHORIZED_KEY={pubkey}", - "-p", - f"{DOCKER_PUBLISH_ADDR}::22", - image_tag, - ]) - - port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout - host_ssh_port = _parse_host_port(port_info) - host = f"root@{DOCKER_SSH_HOST}" - _wait_for_ssh(host, host_ssh_port, key_path) - - with cmux(SOCKET_PATH) as client: - payload = _run_cli_json( - cli, - [ - "ssh", - host, - "--name", - "docker-ssh-port-detection", - "--port", - str(host_ssh_port), - "--identity", - str(key_path), - "--ssh-option", - "UserKnownHostsFile=/dev/null", - "--ssh-option", - "StrictHostKeyChecking=no", - ], - ) - workspace_id = str(payload.get("workspace_id") or "") - workspace_ref = str(payload.get("workspace_ref") or "") - if not workspace_id and workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - workspace_id = str(row.get("id") or "") - break - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") - - ready_status = _wait_remote_ready(client, workspace_id) - initial_remote = ready_status.get("remote") or {} - initial_detected_ports = { - int(value) - for value in (initial_remote.get("detected_ports") or []) - if str(value).isdigit() - } - listed = client._call("workspace.list", {}) or {} - initial_row = next( - (row for row in (listed.get("workspaces") or []) if str(row.get("id") or "") == workspace_id), - {}, - ) - initial_listening_ports = { - int(value) - for value in (initial_row.get("listening_ports") or []) - if str(value).isdigit() - } - _must( - not initial_detected_ports, - f"remote SSH workspace should not surface unrelated startup ports before the shell opens one: {ready_status}", - ) - _must( - not initial_listening_ports, - f"workspace.list should not leak unrelated startup ports before the shell opens one: {initial_row}", - ) - - surfaces = client.list_surfaces(workspace_id) - _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") - surface_id = str(surfaces[0][1]) - startup_command = str(payload.get("ssh_terminal_startup_command") or "") - - server_started_via_surface = True - try: - client.send_surface(surface_id, f"python3 -m http.server {REMOTE_HTTP_PORT}\n") - _wait_surface_contains(client, workspace_id, surface_id, f"port {REMOTE_HTTP_PORT}", timeout=20.0) - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - print("WARN: readable terminal surface unavailable; falling back to generated ssh startup command PTY") - server_started_via_surface = False - else: - raise - - if not server_started_via_surface: - pty_proc, pty_master_fd = _launch_startup_command_pty(startup_command, workspace_id, surface_id) - _wait_surface_tty(client, workspace_id, surface_id, timeout=20.0) - os.write(pty_master_fd, f"python3 -m http.server {REMOTE_HTTP_PORT}\n".encode("utf-8")) - - status, row = _wait_for_remote_port(client, workspace_id, REMOTE_HTTP_PORT, timeout=15.0) - remote = status.get("remote") or {} - detected_ports = { - int(value) - for value in (remote.get("detected_ports") or []) - if str(value).isdigit() - } - listening_ports = { - int(value) - for value in (row.get("listening_ports") or []) - if str(value).isdigit() - } - _must( - REMOTE_HTTP_PORT in detected_ports, - f"remote status should include detected port {REMOTE_HTTP_PORT}: {status}", - ) - _must( - REMOTE_HTTP_PORT in listening_ports, - f"workspace.list should include listening port {REMOTE_HTTP_PORT}: {row}", - ) - - if surface_id: - if pty_master_fd is not None: - os.write(pty_master_fd, b"\x03") - else: - client.send_key_surface(surface_id, "ctrl-c") - if workspace_id: - try: - client.close_workspace(workspace_id) - workspace_id = "" - except Exception: - pass - - print("PASS: remote SSH workspace surfaces listening ports from the live remote shell") - return 0 - - finally: - if pty_master_fd is not None: - try: - os.close(pty_master_fd) - except OSError: - pass - if pty_proc is not None: - if pty_proc.poll() is None: - pty_proc.terminate() - try: - pty_proc.wait(timeout=5.0) - except subprocess.TimeoutExpired: - pty_proc.kill() - - if surface_id and workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.send_key_surface(surface_id, "ctrl-c") - except Exception: - pass - - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_proxy_bind_conflict.py b/tests_v2/test_ssh_remote_proxy_bind_conflict.py deleted file mode 100644 index 32330ccc..00000000 --- a/tests_v2/test_ssh_remote_proxy_bind_conflict.py +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: local proxy bind conflict surfaces proxy_unavailable.""" - -from __future__ import annotations - -import glob -import os -import secrets -import shutil -import socket -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -DOCKER_SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_HOST", "127.0.0.1") -DOCKER_PUBLISH_ADDR = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _parse_host_port(docker_port_output: str) -> int: - text = docker_port_output.strip() - if not text: - raise cmuxError("docker port output was empty") - last = text.split(":")[-1] - return int(last) - - -def _shell_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: - return _run( - [ - "ssh", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "StrictHostKeyChecking=no", - "-o", - "ConnectTimeout=5", - "-p", - str(host_port), - "-i", - str(key_path), - host, - f"sh -lc {_shell_single_quote(script)}", - ], - check=check, - ) - - -def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) - if probe.returncode == 0 and "ready" in probe.stdout: - return - time.sleep(0.5) - raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") - - -def _find_free_loopback_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def _wait_for_proxy_conflict_status(client: cmux, workspace_id: str, expected_local_proxy_port: int, timeout: float = 30.0) -> dict: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - proxy = remote.get("proxy") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "error" and str(proxy.get("state") or "") == "error": - detail = str(remote.get("detail") or "") - _must( - proxy.get("error_code") == "proxy_unavailable", - f"proxy error should be proxy_unavailable under bind conflict: {last_status}", - ) - _must( - int(remote.get("local_proxy_port") or 0) == expected_local_proxy_port, - f"remote status should retain configured local_proxy_port under bind conflict: {last_status}", - ) - _must( - ( - "Failed to start local daemon proxy" in detail - or "Local proxy listener failed" in detail - ), - f"remote detail should surface local proxy bind failure: {last_status}", - ) - _must( - "Address already in use" in detail, - f"remote detail should preserve bind-conflict root cause: {last_status}", - ) - _must( - str(daemon.get("state") or "") == "ready", - f"daemon should remain ready for local-only bind conflicts: {last_status}", - ) - return last_status - time.sleep(0.5) - - raise cmuxError(f"Remote did not reach structured proxy_unavailable status for bind conflict: {last_status}") - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - - _ = _find_cli_binary() # enforce same test prerequisites as other SSH remote suites - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-proxy-conflict-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-proxy-conflict-{secrets.token_hex(4)}" - workspace_id = "" - conflict_listener: socket.socket | None = None - - try: - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _run([ - "docker", "run", "-d", "--rm", - "--name", container_name, - "-e", f"AUTHORIZED_KEY={pubkey}", - "-p", f"{DOCKER_PUBLISH_ADDR}::22", - image_tag, - ]) - - port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout - host_ssh_port = _parse_host_port(port_info) - host = f"root@{DOCKER_SSH_HOST}" - _wait_for_ssh(host, host_ssh_port, key_path) - - conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - conflict_listener.bind(("127.0.0.1", 0)) - conflict_port = int(conflict_listener.getsockname()[1]) - conflict_listener.listen(1) - - with cmux(SOCKET_PATH) as client: - created = client._call("workspace.create", {"initial_command": "echo ssh-proxy-conflict"}) - workspace_id = str((created or {}).get("workspace_id") or "") - _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") - - configured = client._call("workspace.remote.configure", { - "workspace_id": workspace_id, - "destination": host, - "port": host_ssh_port, - "identity_file": str(key_path), - "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], - "auto_connect": True, - "local_proxy_port": conflict_port, - }) - _must(bool(configured), "workspace.remote.configure returned empty response") - - _ = _wait_for_proxy_conflict_status( - client, - workspace_id, - expected_local_proxy_port=conflict_port, - timeout=30.0, - ) - - try: - client.close_workspace(workspace_id) - except Exception: - pass - workspace_id = "" - - print("PASS: local proxy bind conflict surfaces structured proxy_unavailable without degrading daemon readiness") - return 0 - - finally: - if conflict_listener is not None: - try: - conflict_listener.close() - except Exception: - pass - - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_resize_scrollback_regression.py b/tests_v2/test_ssh_remote_resize_scrollback_regression.py deleted file mode 100644 index 79e50e83..00000000 --- a/tests_v2/test_ssh_remote_resize_scrollback_regression.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: ssh workspace keeps large pre-resize scrollback across split resize churn.""" - -from __future__ import annotations - -import glob -import json -import os -import re -import secrets -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() -SSH_PORT = os.environ.get("PROGRAMA_SSH_TEST_PORT", "").strip() -SSH_IDENTITY = os.environ.get("PROGRAMA_SSH_TEST_IDENTITY", "").strip() -SSH_OPTIONS_RAW = os.environ.get("PROGRAMA_SSH_TEST_OPTIONS", "").strip() -LS_ENTRY_COUNT = int(os.environ.get("PROGRAMA_SSH_TEST_LS_COUNT", "320")) -RESIZE_ITERATIONS = int(os.environ.get("PROGRAMA_SSH_TEST_RESIZE_ITERATIONS", "48")) - -ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _wait_remote_connected(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: - deadline = time.time() + timeout_s - last = {} - while time.time() < deadline: - last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return - time.sleep(0.25) - raise cmuxError(f"Remote did not reach connected+ready state: {last}") - - -def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - resolved = str(row.get("id") or "") - if resolved: - return resolved - - current = {wid for _index, wid, _title, _focused in client.list_workspaces()} - new_ids = sorted(current - before_workspace_ids) - if len(new_ids) == 1: - return new_ids[0] - - raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") - - -def _clean_line(raw: str) -> str: - line = OSC_ESCAPE_RE.sub("", raw) - line = ANSI_ESCAPE_RE.sub("", line) - line = line.replace("\r", "") - return line.strip() - - -def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - return str(payload.get("text") or "") - - -def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: - return [_clean_line(raw) for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()] - - -def _wait_surface_contains( - client: cmux, - workspace_id: str, - surface_id: str, - token: str, - *, - exact_line: bool = False, - timeout_s: float = 25.0, -) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if exact_line: - if token in _surface_scrollback_lines(client, workspace_id, surface_id): - return - elif token in _surface_scrollback_text(client, workspace_id, surface_id): - return - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for terminal token: {token}") - - -def _pane_for_surface(client: cmux, surface_id: str) -> str: - target_id = str(client._resolve_surface_id(surface_id)) - for _idx, pane_id, _count, _focused in client.list_panes(): - rows = client.list_pane_surfaces(pane_id) - for _row_idx, sid, _title, _selected in rows: - try: - candidate_id = str(client._resolve_surface_id(sid)) - except cmuxError: - continue - if candidate_id == target_id: - return pane_id - raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") - - -def _valid_resize_directions(client: cmux, workspace_id: str, pane_id: str) -> list[str]: - valid: list[str] = [] - for direction in ("left", "right", "up", "down"): - try: - client._call( - "pane.resize", - { - "workspace_id": workspace_id, - "pane_id": pane_id, - "direction": direction, - "amount": 10, - }, - ) - valid.append(direction) - except cmuxError: - pass - return valid - - -def _choose_resize_pair(client: cmux, workspace_id: str, pane_ids: list[str]) -> list[tuple[str, str]]: - by_pane: dict[str, list[str]] = {} - for pane_id in pane_ids: - by_pane[pane_id] = _valid_resize_directions(client, workspace_id, pane_id) - - for pane_a, directions_a in by_pane.items(): - if "right" not in directions_a: - continue - for pane_b, directions_b in by_pane.items(): - if pane_b == pane_a: - continue - if "left" in directions_b: - return [(pane_a, "right"), (pane_b, "left")] - - for pane_a, directions_a in by_pane.items(): - if "down" not in directions_a: - continue - for pane_b, directions_b in by_pane.items(): - if pane_b == pane_a: - continue - if "up" in directions_b: - return [(pane_a, "down"), (pane_b, "up")] - - raise cmuxError(f"Could not find oscillating resize pair across panes: {by_pane}") - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run remote resize scrollback regression") - return 0 - if LS_ENTRY_COUNT < 64: - print("SKIP: PROGRAMA_SSH_TEST_LS_COUNT must be >= 64 for meaningful scrollback coverage") - return 0 - - cli = _find_cli_binary() - workspace_id = "" - - try: - with cmux(SOCKET_PATH) as client: - before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} - - ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-resize-regression-{secrets.token_hex(4)}"] - if SSH_PORT: - ssh_args.extend(["--port", SSH_PORT]) - if SSH_IDENTITY: - ssh_args.extend(["--identity", SSH_IDENTITY]) - if SSH_OPTIONS_RAW: - for option in SSH_OPTIONS_RAW.split(","): - trimmed = option.strip() - if trimmed: - ssh_args.extend(["--ssh-option", trimmed]) - - payload = _run_cli_json(cli, ssh_args) - workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) - _wait_remote_connected(client, workspace_id, timeout_s=50.0) - - surfaces = client.list_surfaces(workspace_id) - _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") - surface_id = surfaces[0][1] - - stamp = secrets.token_hex(4) - ls_entries = [f"PROGRAMA_REMOTE_RESIZE_LS_{stamp}_{index:04d}.txt" for index in range(1, LS_ENTRY_COUNT + 1)] - ls_start = f"PROGRAMA_REMOTE_RESIZE_LS_START_{stamp}" - ls_end = f"PROGRAMA_REMOTE_RESIZE_LS_END_{stamp}" - - ls_prefix = f"PROGRAMA_REMOTE_RESIZE_LS_{stamp}_" - ls_script = ( - "tmpdir=$(mktemp -d); " - f"echo {ls_start}; " - f"for i in $(seq 1 {LS_ENTRY_COUNT}); do " - "n=$(printf '%04d' \"$i\"); " - f"touch \"$tmpdir/{ls_prefix}$n.txt\"; " - "done; " - "LC_ALL=C CLICOLOR=0 ls -1 \"$tmpdir\"; " - f"echo {ls_end}; " - "rm -rf \"$tmpdir\"" - ) - client.send_surface(surface_id, f"{ls_script}\n") - _wait_surface_contains( - client, - workspace_id, - surface_id, - ls_end, - exact_line=True, - timeout_s=45.0, - ) - - pre_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) - _must( - all(entry in pre_resize_lines for entry in ls_entries), - "pre-resize scrollback missing ls fixture lines in ssh workspace", - ) - pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] - - client.select_workspace(workspace_id) - client.activate_app() - pane_count_before_split = len(client.list_panes()) - client.simulate_shortcut("cmd+d") - _wait_for(lambda: len(client.list_panes()) >= pane_count_before_split + 1, timeout_s=10.0) - - # Ensure the original surface remains selected before resize churn. - client.focus_surface(surface_id) - pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()] - _must(len(pane_ids) >= 2, f"expected split workspace with >=2 panes: {pane_ids}") - _ = _pane_for_surface(client, surface_id) - resize_pair = _choose_resize_pair(client, workspace_id, pane_ids) - - for iteration in range(1, RESIZE_ITERATIONS + 1): - pane_id, direction = resize_pair[(iteration - 1) % len(resize_pair)] - _ = client._call( - "pane.resize", - { - "workspace_id": workspace_id, - "pane_id": pane_id, - "direction": direction, - "amount": 80, - }, - ) - if iteration % 8 == 0: - sampled_lines = _surface_scrollback_lines(client, workspace_id, surface_id) - _must( - all(anchor in sampled_lines for anchor in pre_resize_anchors), - f"resize iteration {iteration} lost pre-resize anchor lines in ssh workspace", - ) - - post_token = f"PROGRAMA_REMOTE_RESIZE_POST_{secrets.token_hex(6)}" - client.send_surface(surface_id, f"echo {post_token}\n") - _wait_surface_contains( - client, - workspace_id, - surface_id, - post_token, - exact_line=True, - timeout_s=25.0, - ) - - post_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) - _must( - all(entry in post_resize_lines for entry in ls_entries), - "post-resize scrollback lost ls fixture lines in ssh workspace", - ) - _must( - post_token in post_resize_lines, - f"post-resize scrollback missing post token: {post_token}", - ) - - client.close_workspace(workspace_id) - workspace_id = "" - - print( - "PASS: cmux ssh split+resize churn preserved large pre-resize scrollback " - f"(entries={LS_ENTRY_COUNT}, iterations={RESIZE_ITERATIONS})" - ) - return 0 - - finally: - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_second_session_mux_regression.py b/tests_v2/test_ssh_remote_second_session_mux_regression.py deleted file mode 100644 index 7994b82c..00000000 --- a/tests_v2/test_ssh_remote_second_session_mux_regression.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: opening a second `cmux ssh` workspace to the same host must not mux-refuse.""" - -from __future__ import annotations - -import glob -import json -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - import subprocess - - proc = subprocess.run( - [cli, "--socket", SOCKET_PATH, "--json", *args], - capture_output=True, - text=True, - check=False, - env=env, - ) - if proc.returncode != 0: - raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return - time.sleep(0.25) - raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") - - -def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: - deadline = time.time() + timeout - while time.time() < deadline: - surfaces = client.list_surfaces(workspace_id) - if surfaces: - return str(surfaces[0][1]) - time.sleep(0.1) - raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") - - -def _workspace_id_from_payload(client: cmux, payload: dict) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] - for row in rows: - if str(row.get("ref") or "") == workspace_ref: - return str(row.get("id") or "") - return "" - - -def _wait_text_contains(client: cmux, surface_id: str, needle: str, timeout: float = 8.0) -> str: - deadline = time.time() + timeout - last = "" - while time.time() < deadline: - last = client.read_terminal_text(surface_id) - if needle in last: - return last - time.sleep(0.1) - raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last[-800:]!r}") - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run second-session ssh mux regression") - return 0 - - cli = _find_cli_binary() - workspace_ids: list[str] = [] - try: - with cmux(SOCKET_PATH) as client: - first = _run_cli_json(cli, ["ssh", SSH_HOST]) - first_workspace_id = _workspace_id_from_payload(client, first) - _must(bool(first_workspace_id), f"first cmux ssh output missing workspace_id: {first}") - workspace_ids.append(first_workspace_id) - _wait_remote_ready(client, first_workspace_id) - first_surface_id = _wait_surface_id(client, first_workspace_id) - _wait_text_contains(client, first_surface_id, "cmux in ~", timeout=12.0) - - second = _run_cli_json(cli, ["ssh", SSH_HOST]) - second_workspace_id = _workspace_id_from_payload(client, second) - _must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}") - _must( - second_workspace_id != first_workspace_id, - f"second cmux ssh should create a distinct workspace: {first_workspace_id} vs {second_workspace_id}", - ) - workspace_ids.append(second_workspace_id) - _wait_remote_ready(client, second_workspace_id) - - second_surface_id = _wait_surface_id(client, second_workspace_id) - text = _wait_text_contains(client, second_surface_id, "cmux in ~", timeout=12.0) - - refusal_markers = [ - "mux_client_request_session: session request failed: Session open refused by peer", - "ControlSocket ", - "disabling multiplexing", - ] - hits = [marker for marker in refusal_markers if marker in text] - _must( - not hits, - "second cmux ssh session printed mux refusal text instead of starting cleanly: " - f"markers={hits!r} tail={text[-1200:]!r}", - ) - - client.send_surface(second_surface_id, "printf '__SECOND_SESSION_OK__\\n'") - text = _wait_text_contains(client, second_surface_id, "__SECOND_SESSION_OK__", timeout=6.0) - _must( - "command not found" not in text, - f"second cmux ssh session accepted corrupted input after startup: {text[-1200:]!r}", - ) - finally: - if workspace_ids: - try: - with cmux(SOCKET_PATH) as client: - for workspace_id in workspace_ids: - try: - client._call("workspace.close", {"workspace_id": workspace_id}) - except Exception: - pass - except Exception: - pass - - print("PASS: second cmux ssh session opens cleanly without mux refusal") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py deleted file mode 100755 index f540bb03..00000000 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ /dev/null @@ -1,804 +0,0 @@ -#!/usr/bin/env python3 -"""Docker integration: prove cmux ssh applies Ghostty ssh-env/ssh-terminfo niceties.""" - -from __future__ import annotations - -import glob -import json -import os -import pty -import re -import secrets -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -DOCKER_SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_HOST", "127.0.0.1") -DOCKER_PUBLISH_ADDR = os.environ.get("PROGRAMA_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") -FIXTURE_REMOTE_HTTP_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_FIXTURE_HTTP_PORT", "43173")) -FIXTURE_REMOTE_WS_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_FIXTURE_WS_PORT", "43174")) -REMOTE_HTTP_PORT = int(os.environ.get("PROGRAMA_SSH_TEST_REMOTE_HTTP_PORT", "8000")) -ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _docker_available() -> bool: - if shutil.which("docker") is None: - return False - probe = _run(["docker", "info"], check=False) - return probe.returncode == 0 - - -def _parse_host_port(docker_port_output: str) -> int: - text = docker_port_output.strip() - if not text: - raise cmuxError("docker port output was empty") - return int(text.split(":")[-1]) - - -def _shell_single_quote(value: str) -> str: - return "'" + value.replace("'", "'\"'\"'") + "'" - - -def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: - return _run( - [ - "ssh", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "StrictHostKeyChecking=no", - "-o", - "ConnectTimeout=5", - "-p", - str(host_port), - "-i", - str(key_path), - host, - f"sh -lc {_shell_single_quote(script)}", - ], - check=check, - ) - - -def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) - if probe.returncode == 0 and "ready" in probe.stdout: - return - time.sleep(0.5) - raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") - - -def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: - deadline = time.time() + timeout - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return last_status - time.sleep(0.4) - raise cmuxError(f"Remote did not reach connected+ready state: {last_status}") - - -def _is_terminal_surface_not_found(exc: Exception) -> bool: - return "terminal surface not found" in str(exc).lower() - - -def _read_probe_value(client: cmux, surface_id: str, command: str, timeout: float = 20.0) -> str: - token = f"__PROGRAMA_PROBE_{secrets.token_hex(6)}__" - client.send_surface(surface_id, f"{command}; printf '{token}%s\\n' $?\\n") - - pattern = re.compile(re.escape(token) + r"([^\r\n]*)") - deadline = time.time() + timeout - saw_missing_surface = False - while time.time() < deadline: - try: - text = client.read_terminal_text(surface_id) - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - saw_missing_surface = True - time.sleep(0.2) - continue - raise - matches = pattern.findall(text) - for raw in reversed(matches): - value = raw.strip() - if value and value != "%s" and "$(" not in value and "printf" not in value: - return value - time.sleep(0.2) - - if saw_missing_surface: - raise cmuxError("terminal surface not found") - raise cmuxError(f"Timed out waiting for probe token for command: {command}") - - -def _read_probe_payload(client: cmux, surface_id: str, payload_command: str, timeout: float = 20.0) -> str: - token = f"__PROGRAMA_PAYLOAD_{secrets.token_hex(6)}__" - client.send_surface(surface_id, f"printf '{token}%s\\n' \"$({payload_command})\"\\n") - - pattern = re.compile(re.escape(token) + r"([^\r\n]*)") - deadline = time.time() + timeout - saw_missing_surface = False - while time.time() < deadline: - try: - text = client.read_terminal_text(surface_id) - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - saw_missing_surface = True - time.sleep(0.2) - continue - raise - matches = pattern.findall(text) - for raw in reversed(matches): - value = raw.strip() - if value and value != "%s" and "$(" not in value and "printf" not in value: - return value - time.sleep(0.2) - - if saw_missing_surface: - raise cmuxError("terminal surface not found") - raise cmuxError(f"Timed out waiting for payload token for command: {payload_command}") - - -def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]: - deadline = time.time() + timeout - last: list[str] = [] - while time.time() < deadline: - last = [pid for _idx, pid, _count, _focused in client.list_panes()] - if len(last) >= minimum_count: - return last - time.sleep(0.1) - raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}") - - -def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str: - payload = client._call( - "surface.read_text", - {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, - ) or {} - return str(payload.get("text") or "") - - -def _clean_line(raw: str) -> str: - line = OSC_ESCAPE_RE.sub("", raw) - line = ANSI_ESCAPE_RE.sub("", line) - line = line.replace("\r", "") - return line.strip() - - -def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: - return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()] - - -def _surface_row(client: cmux, workspace_id: str, surface_id: str) -> dict: - payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return row - raise cmuxError(f"surface.list missing surface {surface_id!r}: {payload}") - - -def _debug_terminal_row(client: cmux, workspace_id: str, surface_id: str) -> dict: - payload = client._call("debug.terminals", {}) or {} - for row in payload.get("terminals") or []: - if str(row.get("workspace_id") or "") == workspace_id and str(row.get("surface_id") or "") == surface_id: - return row - raise cmuxError( - f"debug.terminals missing workspace={workspace_id!r} surface={surface_id!r}: {payload}" - ) - - -def _workspace_row(client: cmux, workspace_id: str) -> dict: - payload = client._call("workspace.list", {}) or {} - for row in payload.get("workspaces") or []: - if str(row.get("id") or "") == workspace_id: - return row - raise cmuxError(f"workspace.list missing workspace {workspace_id!r}: {payload}") - - -def _wait_surface_tty(client: cmux, workspace_id: str, surface_id: str, timeout: float = 20.0) -> str: - deadline = time.time() + timeout - last_row = {} - while time.time() < deadline: - last_row = _debug_terminal_row(client, workspace_id, surface_id) - tty_name = str(last_row.get("tty") or "").strip() - if tty_name: - return tty_name - time.sleep(0.2) - raise cmuxError(f"Timed out waiting for surface tty: {last_row}") - - -def _launch_startup_command_pty(startup_command: str, workspace_id: str, surface_id: str) -> tuple[subprocess.Popen[bytes], int]: - _must(bool(startup_command.strip()), "cmux ssh output missing ssh_terminal_startup_command for PTY fallback") - env = dict(os.environ) - env.pop("PROGRAMA_SOCKET_PATH", None) - env["PROGRAMA_WORKSPACE_ID"] = workspace_id - env["PROGRAMA_SURFACE_ID"] = surface_id - env["PROGRAMA_TAB_ID"] = workspace_id - env["PROGRAMA_PANEL_ID"] = surface_id - - master_fd, slave_fd = pty.openpty() - proc = subprocess.Popen( - ["/bin/sh", "-lc", startup_command], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - env=env, - start_new_session=True, - ) - os.close(slave_fd) - return proc, master_fd - - -def _wait_for_remote_port( - client: cmux, - workspace_id: str, - port: int, - *, - forbidden_ports: set[int] | None = None, - timeout: float = 20.0, -) -> tuple[dict, dict]: - deadline = time.time() + timeout - last_status = {} - last_row = {} - forbidden_ports = forbidden_ports or set() - - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - detected_ports = { - int(value) - for value in (remote.get("detected_ports") or []) - if str(value).isdigit() - } - - last_row = _workspace_row(client, workspace_id) - listening_ports = { - int(value) - for value in (last_row.get("listening_ports") or []) - if str(value).isdigit() - } - - if port in detected_ports and port in listening_ports: - leaked = forbidden_ports.intersection(detected_ports.union(listening_ports)) - if not leaked: - return last_status, last_row - time.sleep(0.3) - - raise cmuxError( - "Timed out waiting for remote shell-integration port detection: " - f"status={last_status} workspace={last_row}" - ) - - -def _assert_remote_ports_absent( - client: cmux, - workspace_id: str, - forbidden_ports: set[int], - *, - timeout: float = 3.0, -) -> tuple[dict, dict]: - deadline = time.time() + timeout - last_status = {} - last_row = {} - - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - detected_ports = { - int(value) - for value in (remote.get("detected_ports") or []) - if str(value).isdigit() - } - - last_row = _workspace_row(client, workspace_id) - listening_ports = { - int(value) - for value in (last_row.get("listening_ports") or []) - if str(value).isdigit() - } - - leaked = forbidden_ports.intersection(detected_ports.union(listening_ports)) - _must( - not leaked, - "remote workspace leaked unrelated host ports before shell-specific detection: " - f"ports={sorted(leaked)} status={last_status} workspace={last_row}", - ) - time.sleep(0.2) - - return last_status, last_row - - -def _scrollback_has_all_lines( - client: cmux, - workspace_id: str, - surface_id: str, - lines: list[str], -) -> bool: - available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id)) - return all(line in available for line in lines) - - -def _wait_surface_contains( - client: cmux, - workspace_id: str, - surface_id: str, - token: str, - *, - timeout: float = 20.0, -) -> None: - deadline = time.time() + timeout - saw_missing_surface = False - while time.time() < deadline: - try: - if token in _surface_text_scrollback(client, workspace_id, surface_id): - return - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - saw_missing_surface = True - time.sleep(0.2) - continue - raise - time.sleep(0.2) - - if saw_missing_surface: - raise cmuxError("terminal surface not found") - raise cmuxError(f"Timed out waiting for terminal token: {token}") - - -def _layout_panes(client: cmux) -> list[dict]: - layout_payload = client.layout_debug() or {} - layout = layout_payload.get("layout") or {} - return list(layout.get("panes") or []) - - -def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: - panes = _layout_panes(client) - for pane in panes: - pid = str(pane.get("paneId") or pane.get("pane_id") or "") - if pid != pane_id: - continue - frame = pane.get("frame") or {} - return float(frame.get(axis) or 0.0) - raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") - - -def _pane_for_surface(client: cmux, surface_id: str) -> str: - target_id = str(client._resolve_surface_id(surface_id)) - for _idx, pane_id, _count, _focused in client.list_panes(): - rows = client.list_pane_surfaces(pane_id) - for _row_idx, sid, _title, _selected in rows: - try: - candidate_id = str(client._resolve_surface_id(sid)) - except cmuxError: - continue - if candidate_id == target_id: - return pane_id - raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") - - -def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: - panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] - if len(panes) < 2: - raise cmuxError(f"Need >=2 panes for resize test, got {panes}") - - def x_of(p: dict) -> float: - return float((p.get("frame") or {}).get("x") or 0.0) - - def y_of(p: dict) -> float: - return float((p.get("frame") or {}).get("y") or 0.0) - - x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) - y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) - - if x_span >= y_span: - left_pane = min(panes, key=x_of) - left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") - return ("right" if target_pane == left_id else "left"), "width" - - top_pane = min(panes, key=y_of) - top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") - return ("down" if target_pane == top_id else "up"), "height" - - -def _wait_readable_terminal_text(client: cmux, surface_id: str, timeout: float = 20.0) -> str: - deadline = time.time() + timeout - saw_missing_surface = False - while time.time() < deadline: - try: - return client.read_terminal_text(surface_id) - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - saw_missing_surface = True - time.sleep(0.2) - continue - raise - - if saw_missing_surface: - raise cmuxError("terminal surface not found") - raise cmuxError(f"Timed out waiting for readable terminal surface: {surface_id}") - - -def main() -> int: - if not _docker_available(): - print("SKIP: docker is not available") - return 0 - if shutil.which("infocmp") is None: - print("SKIP: local infocmp is not available (required for ssh-terminfo)") - return 0 - - cli = _find_cli_binary() - repo_root = Path(__file__).resolve().parents[1] - fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" - _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") - - temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-shell-integration-")) - image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" - container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}" - workspace_id = "" - surface_id = "" - pty_proc: subprocess.Popen[bytes] | None = None - pty_master_fd: int | None = None - - try: - key_path = temp_dir / "id_ed25519" - _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) - pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() - _must(bool(pubkey), "Generated SSH public key was empty") - - _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) - _run([ - "docker", - "run", - "-d", - "--rm", - "--name", - container_name, - "-e", - f"AUTHORIZED_KEY={pubkey}", - "-p", - f"{DOCKER_PUBLISH_ADDR}::22", - image_tag, - ]) - - port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout - host_ssh_port = _parse_host_port(port_info) - host = f"root@{DOCKER_SSH_HOST}" - if shutil.which("ghostty") is not None: - _run(["ghostty", "+ssh-cache", f"--remove={host}"], check=False) - _wait_for_ssh(host, host_ssh_port, key_path) - - pre = _ssh_run(host, host_ssh_port, key_path, "if infocmp xterm-ghostty >/dev/null 2>&1; then echo present; else echo missing; fi") - _must("missing" in pre.stdout, f"Fresh container should not have xterm-ghostty terminfo preinstalled: {pre.stdout!r}") - - with cmux(SOCKET_PATH) as client: - payload = _run_cli_json( - cli, - [ - "ssh", - host, - "--name", - "docker-ssh-shell-integration", - "--port", - str(host_ssh_port), - "--identity", - str(key_path), - "--ssh-option", - "UserKnownHostsFile=/dev/null", - "--ssh-option", - "StrictHostKeyChecking=no", - ], - ) - workspace_id = str(payload.get("workspace_id") or "") - workspace_ref = str(payload.get("workspace_ref") or "") - if not workspace_id and workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - workspace_id = str(row.get("id") or "") - break - _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") - - _wait_remote_connected(client, workspace_id, timeout=45.0) - - surfaces = client.list_surfaces(workspace_id) - _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") - surface_id = str(surfaces[0][1]) - startup_command = str(payload.get("ssh_terminal_startup_command") or "") - _assert_remote_ports_absent( - client, - workspace_id, - {FIXTURE_REMOTE_HTTP_PORT, FIXTURE_REMOTE_WS_PORT}, - timeout=3.0, - ) - try: - terminal_text = _wait_readable_terminal_text(client, surface_id, timeout=5.0) - except cmuxError as exc: - if not _is_terminal_surface_not_found(exc): - raise - print("WARN: readable terminal surface unavailable; falling back to generated ssh startup command PTY") - pty_proc, pty_master_fd = _launch_startup_command_pty(startup_command, workspace_id, surface_id) - _wait_surface_tty(client, workspace_id, surface_id, timeout=20.0) - try: - terminal_text = _wait_readable_terminal_text(client, surface_id, timeout=10.0) - except cmuxError as retry_exc: - if _is_terminal_surface_not_found(retry_exc): - print("SKIP: terminal surface unavailable for shell integration assertions") - return 0 - raise - _must( - "Reconstructed via infocmp" not in terminal_text, - "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", - ) - _must( - "Warning: Failed to install terminfo." not in terminal_text, - "ssh shell bootstrap should not show a false terminfo failure warning", - ) - - try: - term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"") - terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1") - except cmuxError as exc: - if _is_terminal_surface_not_found(exc): - print("SKIP: terminal surface unavailable for shell integration probes") - return 0 - raise - _must(terminfo_state in {"0", "1"}, f"unexpected terminfo probe exit status: {terminfo_state!r}") - if terminfo_state == "0": - _must( - term_value == "xterm-ghostty", - f"when terminfo install succeeds, TERM should remain xterm-ghostty (got {term_value!r})", - ) - else: - _must( - term_value == "xterm-256color", - f"when terminfo is unavailable, ssh-env fallback should use TERM=xterm-256color (got {term_value!r})", - ) - - colorterm_value = _read_probe_payload(client, surface_id, "printf '%s' \"${COLORTERM:-}\"") - _must( - colorterm_value == "truecolor", - f"ssh-env should propagate COLORTERM=truecolor, got: {colorterm_value!r}", - ) - - term_program = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM:-}\"") - _must( - term_program == "ghostty", - f"ssh-env should propagate TERM_PROGRAM=ghostty when AcceptEnv allows it, got: {term_program!r}", - ) - - term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"") - _must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION") - - tty_retry_token = f"PROGRAMA_TTY_RETRY_{secrets.token_hex(6)}" - client.send_surface(surface_id, f"echo {tty_retry_token}\n") - _wait_surface_contains(client, workspace_id, surface_id, tty_retry_token) - - tty_name = _wait_surface_tty(client, workspace_id, surface_id) - _must(bool(tty_name), "remote surface should report a tty once shell integration is active") - - port_token = f"PROGRAMA_REMOTE_HTTP_{secrets.token_hex(6)}" - client.send_surface( - surface_id, - f"python3 -m http.server {REMOTE_HTTP_PORT} >/tmp/programa-http-{port_token}.log 2>&1 & echo {port_token}\n", - ) - _wait_surface_contains(client, workspace_id, surface_id, port_token) - port_status, port_workspace_row = _wait_for_remote_port( - client, - workspace_id, - REMOTE_HTTP_PORT, - forbidden_ports={FIXTURE_REMOTE_HTTP_PORT, FIXTURE_REMOTE_WS_PORT}, - timeout=25.0, - ) - detected_ports = set((port_status.get("remote") or {}).get("detected_ports") or []) - listening_ports = set(port_workspace_row.get("listening_ports") or []) - _must( - FIXTURE_REMOTE_HTTP_PORT not in detected_ports - and FIXTURE_REMOTE_HTTP_PORT not in listening_ports - and FIXTURE_REMOTE_WS_PORT not in detected_ports - and FIXTURE_REMOTE_WS_PORT not in listening_ports, - "workspace should surface only the foreground shell port, not unrelated remote fixture daemons", - ) - - ls_stamp = secrets.token_hex(4) - ls_entries = [f"PROGRAMA_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)] - ls_start = f"PROGRAMA_RESIZE_LS_START_{ls_stamp}" - ls_end = f"PROGRAMA_RESIZE_LS_END_{ls_stamp}" - names = " ".join(ls_entries) - ls_script = ( - "tmpdir=$(mktemp -d); " - f"echo {ls_start}; " - f"for name in {names}; do touch \"$tmpdir/$name\"; done; " - "ls -1 \"$tmpdir\"; " - f"echo {ls_end}; " - "rm -rf \"$tmpdir\"" - ) - client.send_surface(surface_id, f"{ls_script}\n") - _wait_surface_contains(client, workspace_id, surface_id, ls_end) - pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) - _must( - all(line in pre_resize_scrollback_lines for line in ls_entries), - "pre-resize scrollback missing ls output fixture lines", - ) - pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] - _must( - len(pre_resize_anchors) == 3, - f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}", - ) - pre_resize_visible = client.read_terminal_text(surface_id) - pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible] - _must( - len(pre_visible_lines) >= 2, - "pre-resize viewport did not contain enough reference lines for continuity checks", - ) - - client.select_workspace(workspace_id) - client.activate_app() - pane_count_before_split = len(client.list_panes()) - client.simulate_shortcut("cmd+d") - pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0) - - pane_id = _pane_for_surface(client, surface_id) - resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) - opposite_direction = { - "left": "right", - "right": "left", - "up": "down", - "down": "up", - }[resize_direction] - expected_sign_by_direction = { - resize_direction: +1, - opposite_direction: -1, - } - - resize_sequence = [resize_direction, opposite_direction] * 8 - current_extent = _pane_extent(client, pane_id, resize_axis) - for index, direction in enumerate(resize_sequence, start=1): - resize_result = client._call( - "pane.resize", - { - "workspace_id": workspace_id, - "pane_id": pane_id, - "direction": direction, - "amount": 80, - }, - ) or {} - _must( - str(resize_result.get("pane_id") or "") == pane_id, - f"pane.resize response missing expected pane_id: {resize_result}", - ) - if expected_sign_by_direction[direction] > 0: - _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0) - else: - _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0) - current_extent = _pane_extent(client, pane_id, resize_axis) - _must( - _scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors), - f"resize iteration {index} lost pre-resize scrollback anchors", - ) - - post_resize_visible = client.read_terminal_text(surface_id) - visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] - _must( - bool(visible_overlap), - f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", - ) - - resize_post_token = f"PROGRAMA_RESIZE_POST_{secrets.token_hex(6)}" - client.send_surface(surface_id, f"echo {resize_post_token}\n") - _wait_surface_contains(client, workspace_id, surface_id, resize_post_token) - - scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) - _must( - all(anchor in scrollback_lines for anchor in pre_resize_anchors), - "terminal scrollback lost pre-resize lines after pane resize", - ) - _must( - resize_post_token in scrollback_lines, - f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}", - ) - - try: - client.close_workspace(workspace_id) - workspace_id = "" - except Exception: - pass - - print( - "PASS: cmux ssh enables Ghostty shell integration niceties and preserves pre-resize terminal content " - f"(TERM={term_value}, COLORTERM={colorterm_value}, TERM_PROGRAM={term_program})" - ) - return 0 - - finally: - if pty_master_fd is not None: - try: - os.close(pty_master_fd) - except OSError: - pass - if pty_proc is not None and pty_proc.poll() is None: - pty_proc.terminate() - try: - pty_proc.wait(timeout=5.0) - except subprocess.TimeoutExpired: - pty_proc.kill() - - if workspace_id: - try: - with cmux(SOCKET_PATH) as cleanup_client: - cleanup_client.close_workspace(workspace_id) - except Exception: - pass - - _run(["docker", "rm", "-f", container_name], check=False) - _run(["docker", "rmi", "-f", image_tag], check=False) - shutil.rmtree(temp_dir, ignore_errors=True) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shortcuts_stay_remote.py b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py deleted file mode 100644 index 80f47a6a..00000000 --- a/tests_v2/test_ssh_remote_shortcuts_stay_remote.py +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: new tabs and splits from an ssh terminal must stay on the remote shell.""" - -from __future__ import annotations - -import glob -import json -import os -import re -import secrets -import subprocess -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") -SSH_HOST = os.environ.get("PROGRAMA_SSH_TEST_HOST", "").strip() -SSH_PORT = os.environ.get("PROGRAMA_SSH_TEST_PORT", "").strip() -SSH_IDENTITY = os.environ.get("PROGRAMA_SSH_TEST_IDENTITY", "").strip() -SSH_OPTIONS_RAW = os.environ.get("PROGRAMA_SSH_TEST_OPTIONS", "").strip() - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: - proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) - if check and proc.returncode != 0: - merged = f"{proc.stdout}\n{proc.stderr}".strip() - raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") - return proc - - -def _find_cli_binary() -> str: - env_cli = os.environ.get("CMUXTERM_CLI") - if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): - return env_cli - - fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") - if os.path.isfile(fixed) and os.access(fixed, os.X_OK): - return fixed - - candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) - candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") - candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] - if not candidates: - raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") - candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) - return candidates[0] - - -def _run_cli_json(cli: str, args: list[str]) -> dict: - env = dict(os.environ) - env.pop("PROGRAMA_WORKSPACE_ID", None) - env.pop("PROGRAMA_SURFACE_ID", None) - env.pop("PROGRAMA_TAB_ID", None) - - proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) - try: - return json.loads(proc.stdout or "{}") - except Exception as exc: # noqa: BLE001 - raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") - - -def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: - deadline = time.time() + timeout_s - while time.time() < deadline: - if pred(): - return - time.sleep(step_s) - raise cmuxError("Timed out waiting for condition") - - -def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: - deadline = time.time() + timeout_s - last_status = {} - while time.time() < deadline: - last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} - remote = last_status.get("remote") or {} - daemon = remote.get("daemon") or {} - if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": - return - time.sleep(0.25) - raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") - - -def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: - workspace_id = str(payload.get("workspace_id") or "") - if workspace_id: - return workspace_id - - workspace_ref = str(payload.get("workspace_ref") or "") - if workspace_ref.startswith("workspace:"): - listed = client._call("workspace.list", {}) or {} - for row in listed.get("workspaces") or []: - if str(row.get("ref") or "") == workspace_ref: - resolved = str(row.get("id") or "") - if resolved: - return resolved - - current = {wid for _index, wid, _title, _focused in client.list_workspaces()} - new_ids = sorted(current - before_workspace_ids) - if len(new_ids) == 1: - return new_ids[0] - - raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") - - -def _focused_surface_id(client: cmux) -> str: - ident = client.identify() - focused = ident.get("focused") or {} - surface_id = str(focused.get("surface_id") or "") - if not surface_id: - raise cmuxError(f"Missing focused surface in identify payload: {ident}") - return surface_id - - -def _run_remote_shell_probe(client: cmux, surface_id: str, probe_label: str) -> str: - token = f"__PROGRAMA_REMOTE_SOCKET_{probe_label}_{secrets.token_hex(4)}__" - client.send_surface( - surface_id, - ( - f"__cmux_socket_path=\"${{PROGRAMA_SOCKET_PATH:-}}\"; " - f"printf '{token}:%s:__PROGRAMA_REMOTE_SOCKET_END__\\n' \"$__cmux_socket_path\"\n" - ), - ) - deadline = time.time() + 15.0 - last = "" - pattern = re.compile(re.escape(token) + r":(.*?):__PROGRAMA_REMOTE_SOCKET_END__") - while time.time() < deadline: - last = client.read_terminal_text(surface_id) - matches = pattern.findall(last) - if matches: - for candidate in reversed(matches): - cleaned = candidate.strip() - if cleaned and cleaned != "%s": - return cleaned - time.sleep(0.15) - raise cmuxError(f"Timed out waiting for socket token {token!r}: {last[-1200:]!r}") - - -def _assert_remote_socket_path(client: cmux, surface_id: str, shortcut_name: str) -> None: - socket_path = _run_remote_shell_probe(client, surface_id, shortcut_name) - _must( - socket_path.startswith("127.0.0.1:"), - f"{shortcut_name} should keep the new terminal on the ssh relay, got PROGRAMA_SOCKET_PATH={socket_path!r}", - ) - - -def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: - before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} - - ssh_args = ["ssh", SSH_HOST, "--name", name] - if SSH_PORT: - ssh_args.extend(["--port", SSH_PORT]) - if SSH_IDENTITY: - ssh_args.extend(["--identity", SSH_IDENTITY]) - if SSH_OPTIONS_RAW: - for option in SSH_OPTIONS_RAW.split(","): - trimmed = option.strip() - if trimmed: - ssh_args.extend(["--ssh-option", trimmed]) - - payload = _run_cli_json(cli, ssh_args) - workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) - _wait_remote_ready(client, workspace_id) - client.select_workspace(workspace_id) - _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) - return workspace_id - - -def _assert_shortcut_creates_remote_terminal( - client: cmux, - workspace_id: str, - shortcut: str, - shortcut_name: str, - *, - expect_new_pane: bool, -) -> None: - before_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_pane_count = len(client.list_panes()) - - client.activate_app() - client.simulate_app_active() - client.simulate_shortcut(shortcut) - - _wait_for( - lambda: len({sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_surfaces) == 1, - timeout_s=12.0, - ) - - if expect_new_pane: - _wait_for(lambda: len(client.list_panes()) >= before_pane_count + 1, timeout_s=12.0) - - after_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - new_surface_ids = sorted(after_surfaces - before_surfaces) - _must(len(new_surface_ids) == 1, f"{shortcut_name} should create exactly one new surface: {new_surface_ids}") - - focused_surface_id = _focused_surface_id(client) - _must( - focused_surface_id == new_surface_ids[0], - f"{shortcut_name} should focus the new terminal surface: focused={focused_surface_id!r} new={new_surface_ids[0]!r}", - ) - _assert_remote_socket_path(client, focused_surface_id, shortcut_name) - - -def main() -> int: - if not SSH_HOST: - print("SKIP: set PROGRAMA_SSH_TEST_HOST to run ssh shortcut inheritance regression") - return 0 - - cli = _find_cli_binary() - workspace_ids: list[str] = [] - - try: - with cmux(SOCKET_PATH) as client: - workspace_id = _open_ssh_workspace( - client, - cli, - name=f"ssh-shortcut-cmdt-{secrets.token_hex(4)}", - ) - workspace_ids.append(workspace_id) - _assert_shortcut_creates_remote_terminal( - client, - workspace_id, - "cmd+t", - "cmd+t", - expect_new_pane=False, - ) - - workspace_id = _open_ssh_workspace( - client, - cli, - name=f"ssh-shortcut-cmdd-{secrets.token_hex(4)}", - ) - workspace_ids.append(workspace_id) - _assert_shortcut_creates_remote_terminal( - client, - workspace_id, - "cmd+d", - "cmd+d", - expect_new_pane=True, - ) - - workspace_id = _open_ssh_workspace( - client, - cli, - name=f"ssh-shortcut-cmdshiftd-{secrets.token_hex(4)}", - ) - workspace_ids.append(workspace_id) - _assert_shortcut_creates_remote_terminal( - client, - workspace_id, - "cmd+shift+d", - "cmd+shift+d", - expect_new_pane=True, - ) - finally: - if workspace_ids: - try: - with cmux(SOCKET_PATH) as client: - for workspace_id in workspace_ids: - try: - client._call("workspace.close", {"workspace_id": workspace_id}) - except Exception: - pass - except Exception: - pass - - print("PASS: cmd+t/cmd+d/cmd+shift+d keep ssh terminals on the remote relay") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/mobile-spike/Package.swift b/tools/mobile-spike/Package.swift deleted file mode 100644 index da3b1065..00000000 --- a/tools/mobile-spike/Package.swift +++ /dev/null @@ -1,20 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "mobile-spike", - platforms: [ - .macOS(.v14) - ], - products: [ - .library(name: "MobileSpikeFraming", targets: ["MobileSpikeFraming"]), - ], - targets: [ - .target(name: "MobileSpikeFraming"), - .testTarget( - name: "MobileSpikeFramingTests", - dependencies: ["MobileSpikeFraming"] - ), - ] -) diff --git a/tools/mobile-spike/README.md b/tools/mobile-spike/README.md deleted file mode 100644 index 552f9058..00000000 --- a/tools/mobile-spike/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Mobile spike framing - -This package retains the bounded newline framer proven by the original M0 macOS -`iroh-spike` prototype. The production transport and pairing flow now live in -`ios/ProgramaSpike`; the completed prototype executable and its direct -`iroh-ffi` dependency were removed. Canonical upstream-wrapper provenance stays -in `vendor/CmuxIrohTransport/PROVENANCE.md`. diff --git a/tools/mobile-spike/Sources/MobileSpikeFraming/BoundedLineFramer.swift b/tools/mobile-spike/Sources/MobileSpikeFraming/BoundedLineFramer.swift deleted file mode 100644 index d207b245..00000000 --- a/tools/mobile-spike/Sources/MobileSpikeFraming/BoundedLineFramer.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation - -public enum BoundedLineFramerError: Error, Equatable, Sendable { - case frameTooLarge -} - -/// Incrementally splits a byte stream on newlines while enforcing a hard -/// frame-size ceiling. Each byte is scanned at most once. One owner must -/// serialize calls to `nextLine`; both current clients have exactly one reader. -public final class BoundedLineFramer: @unchecked Sendable { - public static let maximumLineByteCount = 8 * 1024 * 1024 - - private var buffer = Data() - private var scannedByteCount = 0 - private let chunkSize = 65_536 - - public init() {} - - /// Returns the next line without its newline, or the remaining bytes at - /// EOF. The read closure is never asked for enough bytes to let the buffer - /// grow beyond the maximum frame size plus one delimiter byte. - public func nextLine( - readChunk: (_ sizeLimit: UInt32) async throws -> Data - ) async throws -> Data? { - while true { - let searchStartIndex = buffer.index( - buffer.startIndex, - offsetBy: scannedByteCount - ) - if let newlineIndex = buffer[searchStartIndex...].firstIndex(of: 0x0A) { - guard buffer.distance(from: buffer.startIndex, to: newlineIndex) - <= Self.maximumLineByteCount - else { - throw BoundedLineFramerError.frameTooLarge - } - let line = Data(buffer[buffer.startIndex ..< newlineIndex]) - buffer.removeSubrange(buffer.startIndex ... newlineIndex) - scannedByteCount = 0 - return line - } - scannedByteCount = buffer.count - guard buffer.count <= Self.maximumLineByteCount else { - throw BoundedLineFramerError.frameTooLarge - } - - let bytesUntilOverflow = Self.maximumLineByteCount + 1 - buffer.count - let readLimit = UInt32(min(chunkSize, bytesUntilOverflow)) - let chunk = try await readChunk(readLimit) - if chunk.isEmpty { - if !buffer.isEmpty { - let remaining = buffer - buffer.removeAll() - scannedByteCount = 0 - return remaining - } - scannedByteCount = 0 - return nil - } - buffer.append(chunk) - } - } -} diff --git a/tools/mobile-spike/Tests/MobileSpikeFramingTests/BoundedLineFramerTests.swift b/tools/mobile-spike/Tests/MobileSpikeFramingTests/BoundedLineFramerTests.swift deleted file mode 100644 index 0db6a2d5..00000000 --- a/tools/mobile-spike/Tests/MobileSpikeFramingTests/BoundedLineFramerTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import MobileSpikeFraming -import Testing - -@Test func returnsLinesAndEOFWithoutRescanning() async throws { - let framer = BoundedLineFramer() - var chunks = [Data("first\nsecond".utf8), Data()] - - let first = try await framer.nextLine { _ in chunks.removeFirst() } - let second = try await framer.nextLine { _ in chunks.removeFirst() } - let end = try await framer.nextLine { _ in Data() } - - #expect(first == Data("first".utf8)) - #expect(second == Data("second".utf8)) - #expect(end == nil) -} - -@Test func rejectsAFrameBeyondTheEightMiBCeiling() async throws { - let framer = BoundedLineFramer() - var remaining = BoundedLineFramer.maximumLineByteCount + 1 - - await #expect(throws: BoundedLineFramerError.frameTooLarge) { - _ = try await framer.nextLine { limit in - let count = min(Int(limit), remaining) - remaining -= count - return Data(repeating: 0x61, count: count) - } - } -} - -@Test func acceptsExactlyEightMiBBeforeTheDelimiter() async throws { - let framer = BoundedLineFramer() - var bytes = Data(repeating: 0x61, count: BoundedLineFramer.maximumLineByteCount) - bytes.append(0x0A) - - let line = try await framer.nextLine { limit in - let count = min(Int(limit), bytes.count) - let chunk = Data(bytes.prefix(count)) - bytes.removeFirst(count) - return chunk - } - - #expect(line?.count == BoundedLineFramer.maximumLineByteCount) -} diff --git a/vendor/CMUXMobileCore/PROVENANCE.md b/vendor/CMUXMobileCore/PROVENANCE.md deleted file mode 100644 index e1298fef..00000000 --- a/vendor/CMUXMobileCore/PROVENANCE.md +++ /dev/null @@ -1,22 +0,0 @@ -# Provenance - -Vendored from the upstream `cmux` fork. Not a submodule, not a live SPM -dependency — a one-time source copy, following the `vendor/bonsplit` precedent. - -| | | -|---|---| -| Source repo | `upstream` remote (cmux) | -| Source path | `Packages/Shared/CMUXMobileCore` | -| Commit | `34cc2ba5110adf45c27607e865be5867fbcad8a9` (`upstream/main`) | -| Extracted | 2026-07-27 | - -Re-extract with: - -```sh -git archive 34cc2ba5110adf45c27607e865be5867fbcad8a9 Packages/Shared/CMUXMobileCore \ - | tar -x --strip-components=3 -C vendor/CMUXMobileCore -``` - -There is no live tracking of upstream after this point. See -`plans/golden-tumbling-gray.md` for why (4,706-commit divergence, and upstream -commits risk reintroducing the account/broker coupling this port deliberately removes). diff --git a/vendor/CMUXMobileCore/Package.swift b/vendor/CMUXMobileCore/Package.swift deleted file mode 100644 index fbc1acc6..00000000 --- a/vendor/CMUXMobileCore/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "CMUXMobileCore", - platforms: [ - .iOS(.v18), - .macOS(.v14), - ], - products: [ - .library( - name: "CMUXMobileCore", - targets: ["CMUXMobileCore"] - ), - ], - targets: [ - .target( - name: "CMUXMobileCore", - swiftSettings: [.swiftLanguageMode(.v6)] - ), - .testTarget( - name: "CMUXMobileCoreTests", - dependencies: ["CMUXMobileCore"], - swiftSettings: [.swiftLanguageMode(.v6)] - ), - ] -) diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsEmitting.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsEmitting.swift deleted file mode 100644 index 7a4beebd..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsEmitting.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation - -/// The fire-and-forget analytics seam every iOS fire-site depends on. -/// -/// This is the single injection point for product analytics in the mobile app. -/// It lives in `CMUXMobileCore` (the universally-imported, dependency-free base -/// package) so the lowest fire-site — `MobileClientIDRepository` in -/// `CmuxMobileShellModel` — can depend on the seam without any upward edge. The -/// concrete `AnalyticsEmitter` actor lives in `CmuxMobileAnalytics`, is built -/// once at the app composition root, and is injected here as `any -/// AnalyticsEmitting`. Tests and SwiftUI previews use ``NoopAnalytics`` or a -/// recording fake. -/// -/// ### Non-blocking contract -/// -/// ``capture(_:_:)`` is **synchronous, non-throwing, and returns immediately**. -/// It must never be `await`-ed inline on a hot path (terminal input, render), -/// and conformers must never do network or disk I/O on the calling thread: -/// `capture` enqueues onto an off-main actor and returns. Fire-sites call -/// `analytics.capture("ios_event", props)` with **no `await`**. -/// -/// ```swift -/// // On the terminal-input hot path — note: no await. -/// analytics.capture("ios_terminal_input_submitted", [ -/// "byte_count": .int(payload.utf8.count), -/// "line_count": .int(lineCount), -/// ]) -/// ``` -public protocol AnalyticsEmitting: Sendable { - /// Records a single product event. Returns immediately; emission is async. - /// - /// - Parameters: - /// - event: The `ios_`-prefixed snake_case event name. - /// - properties: Event properties. Sizes, counts, durations, flags, and - /// short enum strings only — never user content. - func capture(_ event: String, _ properties: [String: AnalyticsValue]) - - /// Associates subsequent events with a stable user identity. - /// - /// Called once at sign-in completion so the pre-auth anonymous funnel merges - /// into the authenticated user profile. - /// - /// - Parameters: - /// - userId: The stable identifier (the Stack user id), or `nil` to reset - /// to anonymous on sign-out. - /// - alias: A prior anonymous id to alias into `userId`, if any. - /// - properties: Person properties to set on the identified profile. - func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) - - /// Sets super-properties merged into every subsequent event. - /// - /// Super-properties (app version, OS, device model, paired-mac count, …) are - /// set once at identity and refreshed only when they change, never repeated - /// per `capture` call by the caller. - /// - /// - Parameter properties: The super-properties to merge and persist. - func setSuperProperties(_ properties: [String: AnalyticsValue]) - - /// Flushes any buffered events immediately. - /// - /// Awaited at app-background so queued events survive suspension. On hot - /// paths, never call this — rely on the size/cadence triggers instead. - func flush() async -} - -extension AnalyticsEmitting { - /// Records an event with no properties. - public func capture(_ event: String) { - capture(event, [:]) - } - - /// Associates a user identity with no alias or person properties. - public func identify(userId: String?) { - identify(userId: userId, alias: nil, properties: [:]) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsValue.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsValue.swift deleted file mode 100644 index 966efc29..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/AnalyticsValue.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// A single property value attached to an analytics event. -/// -/// Analytics properties are deliberately restricted to a small set of JSON-safe -/// scalars so the emitter can serialize a batch without reflection and so the -/// privacy posture is obvious at the call site: only sizes, counts, flags, and -/// short enum strings ever flow through. The terminal text, paste contents, -/// search queries, host names, IPs, tickets, and tokens are *never* represented -/// here — by construction the catalog only ever passes counts and enums. -/// -/// ```swift -/// let props: [String: AnalyticsValue] = [ -/// "byte_count": .int(payload.utf8.count), -/// "method": .string("qr"), -/// "is_first_pair": .bool(true), -/// ] -/// ``` -public enum AnalyticsValue: Sendable, Equatable { - /// A string value, used for enum-style discriminators (never free text). - case string(String) - /// An integer value, used for counts, sizes, and durations in milliseconds. - case int(Int) - /// A floating-point value, used for fractional measurements. - case double(Double) - /// A boolean flag. - case bool(Bool) - - /// The value rendered as a `Sendable` JSON-encodable object. - /// - /// Used by the emitter when assembling a batch payload for the capture - /// endpoint. The returned values are all property-list/JSON safe. - public var jsonObject: any Sendable { - switch self { - case let .string(value): return value - case let .int(value): return value - case let .double(value): return value - case let .bool(value): return value - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CellRelativeColor.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CellRelativeColor.swift deleted file mode 100644 index 68a2fb8d..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CellRelativeColor.swift +++ /dev/null @@ -1,9 +0,0 @@ -extension TerminalTheme { - /// A Ghostty color resolved from the cell beneath a cursor or selection. - public enum CellRelativeColor: String, Codable, Equatable, Sendable { - /// Use the rendered cell foreground. - case foreground = "cell-foreground" - /// Use the rendered cell background. - case background = "cell-background" - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachRouteDisclosure.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachRouteDisclosure.swift deleted file mode 100644 index 84854946..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachRouteDisclosure.swift +++ /dev/null @@ -1,16 +0,0 @@ -/// The disclosure boundary applied before serializing attach routes. -public enum CmxAttachRouteDisclosure: Equatable, Sendable { - /// Same-account registry, presence, or local persistence. - case authenticated - /// Cloud rendezvous shared with other authenticated devices. Iroh identity - /// and relay bootstrap are retained; direct path and network-profile - /// metadata stay device-local. - case cloudRendezvous - /// An unauthenticated network status response. - case publicStatus - /// A scannable pairing payload. - case pairingQRCode - /// The paired-Mac server backup. Iroh uses the same relay-only boundary as - /// cloud rendezvous. - case pairedMacCloudBackup -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift deleted file mode 100644 index 7f75693c..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoder.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation - -/// Codes ``CmxAttachTicket`` to and from the compact wire form used by the -/// pairing QR payload. -/// -/// The pairing QR encodes `cmux-ios://attach?v=1&payload=<base64url(JSON)>`. -/// The legacy JSON spelled out full camelCase keys plus a vestigial -/// `auth_token`, which pushed the QR into a denser version than necessary. -/// The compact grammar keeps the same envelope but encodes only what pairing -/// actually consumes: short keys, no empty optional fields, no auth token, no -/// display name (read post-handshake from `mobile.host.status`), and no -/// expiry. It does keep non-secret pairing context: the Mac account email, -/// shared pairing compatibility level, and app version/build, so the phone can -/// fail fast on an account mismatch and warn before continuing across -/// compatibility skew. A pairing QR never expires; -/// the owner's Stack access token is the -/// host's sole authorization gate (`MobileHostService.authorizationError(for:)`), -/// so ticket age authorizes nothing. -/// -/// Compatibility: -/// - New decoders accept both grammars: ``CmxAttachTicketInput`` routes a -/// payload whose top-level object carries `"v"` here and everything else -/// (legacy `"version"` payloads) through the original `Codable` path. -/// - Payloads from the first compact revision still decode: their extra `e` -/// (expiry) and `n` (display name) keys are ignored, their explicit route -/// `i` ids and endpoint `t` types are honored. -/// - Old decoders reject the compact grammar loudly (a `DecodingError` from -/// the missing `"version"` key), so an outdated phone scanning a new QR -/// shows a pairing error instead of silently misreading the ticket. -/// -/// Key map (ticket): `v` version, `w` workspaceID (omitted when empty), -/// `t` terminalID, `d` macDeviceID, `u` Mac account email, `pc` pairing -/// compatibility version, `av` app version, `ab` app build, `r` routes. -/// Key map (route): `i` id (omitted when the decoder can resynthesize it: -/// `kind` for the first route of a kind, `kind_N` for the Nth), `k` kind raw -/// value, `p` priority (omitted when 0), `e` endpoint. -/// Key map (endpoint): the type is implied by the keys present (accepted -/// explicitly under `t` for first-revision payloads): `h` host + `p` port, or -/// `i` peer id, or `u` url. New pairing payloads carry no Iroh path hints: -/// managed relays are app configuration, online discovery is authenticated, -/// and offline pairing resolves the scanned EndpointID locally. Decoding still -/// accepts the first compact revision's `ph`, `rh`, `da`, and `ru` fields. -public struct CmxAttachTicketCompactCoder: Sendable { - /// Creates a coder. The coder is stateless; instances are interchangeable. - public init() {} - - /// Encode a ticket into the compact JSON grammar. - /// - /// Any `authToken`, `macDisplayName`, and `expiresAt` on the ticket are - /// intentionally not encoded: the token never authorizes anything on the - /// host (Stack auth is the sole gate), the name is read post-handshake - /// from `mobile.host.status`, and a pairing QR never expires. Callers must - /// explicitly select identity-only disclosure or the temporary released- - /// client compatibility mode. - public func encode( - _ ticket: CmxAttachTicket, - routeDisclosureMode: CmxPairingRouteDisclosureMode - ) throws -> Data { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - return try encoder.encode(CompactAttachTicket( - ticket, - routeDisclosureMode: routeDisclosureMode - )) - } - - /// Decode a compact JSON payload into a validated ``CmxAttachTicket``. - public func decode(_ data: Data) throws -> CmxAttachTicket { - try JSONDecoder().decode(CompactAttachTicket.self, from: data).ticket() - } - - /// Whether a decoded `payload` blob speaks the compact grammar. - /// - /// Compact payloads carry the version under `"v"`; legacy payloads carry - /// it under `"version"`. Non-JSON input returns `false` so the caller - /// falls through to the legacy decoder, which throws a proper error. - public func isCompactPayload(_ data: Data) -> Bool { - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return false - } - return object["v"] != nil - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoderError.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoderError.swift deleted file mode 100644 index b9d7fcdc..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxAttachTicketCompactCoderError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Failures while applying a pairing-route disclosure policy. -public enum CmxAttachTicketCompactCoderError: Error, Equatable, Sendable { - /// The selected disclosure mode removed every route from the payload. - case noRoutesForDisclosureMode(CmxPairingRouteDisclosureMode) -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxByteTransportRequest.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxByteTransportRequest.swift deleted file mode 100644 index 021b9be7..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxByteTransportRequest.swift +++ /dev/null @@ -1,31 +0,0 @@ -/// The authorization already established before application bytes are sent. -public enum CmxTransportAuthorizationMode: Equatable, Sendable { - /// RPC requests must add a Stack bearer on an approved transport. - case stackBearer - /// A pairing established before Iroh may send a Stack bearer only to the - /// exact Tailscale peer captured by this persisted compatibility grant. - case legacyTailscaleBearer(CmxLegacyTailscaleAuthorizationEvidence) - /// The transport handshake admitted this exact peer and account binding. - case transportAdmission -} - -/// Route plus peer intent required to build a transport without substitution. -public struct CmxByteTransportRequest: Equatable, Sendable { - public let route: CmxAttachRoute - public let expectedPeerDeviceID: String? - public let authorizationMode: CmxTransportAuthorizationMode - /// The local owner whose network path this request represents. - public let sessionPurpose: CmxTransportSessionPurpose - - public init( - route: CmxAttachRoute, - expectedPeerDeviceID: String?, - authorizationMode: CmxTransportAuthorizationMode, - sessionPurpose: CmxTransportSessionPurpose = .foregroundControl - ) { - self.route = route - self.expectedPeerDeviceID = expectedPeerDeviceID - self.authorizationMode = authorizationMode - self.sessionPurpose = sessionPurpose - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxCredentialedHTTPSession.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxCredentialedHTTPSession.swift deleted file mode 100644 index 3ca320fe..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxCredentialedHTTPSession.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation - -public enum CmxCredentialedHTTPSessionError: Error, Equatable, Sendable { - case responseTooLarge -} - -/// A cookie-free ephemeral URL session for requests that carry account secrets. -/// -/// Redirects are rejected before Foundation can reconstruct and forward a -/// request. This is required for custom credential headers because Foundation's -/// normal cross-origin redirect handling strips `Authorization` but can preserve -/// unrelated headers such as a refresh token. -public final class CmxCredentialedHTTPSession: @unchecked Sendable { - public static let defaultMaximumResponseByteCount = 4 * 1_024 * 1_024 - - private let redirectDelegate: CmxCredentialedHTTPRedirectDelegate - private let session: URLSession - private let maximumResponseByteCount: Int - - public init( - configuration: sending URLSessionConfiguration = .ephemeral, - maximumResponseByteCount: Int = defaultMaximumResponseByteCount - ) { - precondition(maximumResponseByteCount > 0) - configuration.httpShouldSetCookies = false - configuration.httpCookieStorage = nil - configuration.urlCache = nil - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - let redirectDelegate = CmxCredentialedHTTPRedirectDelegate() - self.redirectDelegate = redirectDelegate - self.maximumResponseByteCount = maximumResponseByteCount - session = URLSession( - configuration: configuration, - delegate: redirectDelegate, - delegateQueue: nil - ) - } - - public func data(for request: URLRequest) async throws -> (Data, URLResponse) { - let (bytes, response) = try await session.bytes(for: request) - if response.expectedContentLength > maximumResponseByteCount { - bytes.task.cancel() - throw CmxCredentialedHTTPSessionError.responseTooLarge - } - var data = Data() - if response.expectedContentLength > 0 { - data.reserveCapacity( - min(Int(response.expectedContentLength), maximumResponseByteCount) - ) - } - for try await byte in bytes { - guard data.count < maximumResponseByteCount else { - bytes.task.cancel() - throw CmxCredentialedHTTPSessionError.responseTooLarge - } - data.append(byte) - } - return (data, response) - } - - deinit { - session.invalidateAndCancel() - } -} - -final class CmxCredentialedHTTPRedirectDelegate: NSObject, - URLSessionTaskDelegate, - @unchecked Sendable -{ - func urlSession( - _: URLSession, - task _: URLSessionTask, - willPerformHTTPRedirection _: HTTPURLResponse, - newRequest _: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void - ) { - completionHandler(nil) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift deleted file mode 100644 index 4b14c961..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// Returns one stable lowercase spelling for a UUID device identifier. -/// -/// Device identifiers outside the UUID grammar are opaque protocol values and -/// are returned byte-for-byte, including their original case and whitespace. -public func cmxCanonicalDeviceID(_ deviceID: String) -> String { - guard let uuid = UUID(uuidString: deviceID) else { return deviceID } - return uuid.uuidString.lowercased() -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomPrivateAddress.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomPrivateAddress.swift deleted file mode 100644 index aa99c98e..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomPrivateAddress.swift +++ /dev/null @@ -1,128 +0,0 @@ -import Darwin -import Foundation - -/// One canonical numeric address for a user-configured private path. -/// -/// This value intentionally carries no port or identity. Dial-time policy joins -/// it to the broker-authenticated Mac's current Iroh UDP port and EndpointID. -public struct CmxIrohCustomPrivateAddress: Codable, Equatable, Hashable, Sendable { - private enum CodingKeys: String, CodingKey { - case value - case family - } - public enum Family: String, Codable, Sendable { - case ipv4 - case ipv6 - } - - /// Canonical numeric IPv4 or IPv6 text without brackets, a port, or a zone. - public let value: String - public let family: Family - - /// Parses, canonicalizes, and validates one numeric private-path address. - /// - /// Hostnames, socket ports, loopback, multicast, link-local, wildcard, and - /// scoped IPv6 addresses are rejected. Globally shaped addresses remain - /// eligible because the authenticated Iroh handshake, not the coordinate, - /// proves the remote Mac. - public init(_ rawValue: String) throws { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - trimmed.utf8.count <= 64, - !trimmed.contains("%"), - !trimmed.contains("["), - !trimmed.contains("]") else { - throw CmxIrohCustomPrivateAddressError.invalidAddress - } - - let canonical: String - let family: Family - if let value = Self.canonicalIPv4(trimmed) { - canonical = value - family = .ipv4 - } else if let value = Self.canonicalIPv6(trimmed) { - canonical = value - family = .ipv6 - } else { - throw CmxIrohCustomPrivateAddressError.invalidAddress - } - - do { - let profile = try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: String(repeating: "0", count: 64) - ) - let observedAt = Date(timeIntervalSince1970: 0) - _ = try CmxIrohPathHint( - kind: .directAddress, - value: family == .ipv4 ? "\(canonical):1" : "[\(canonical)]:1", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: observedAt, - expiresAt: observedAt.addingTimeInterval(1), - networkProfile: profile - ) - } catch { - throw CmxIrohCustomPrivateAddressError.invalidAddress - } - - self.value = canonical - self.family = family - } - - /// Builds the Iroh socket coordinate using an authenticated UDP port. - public func socketAddress(port: UInt16) -> String { - switch family { - case .ipv4: "\(value):\(port)" - case .ipv6: "[\(value)]:\(port)" - } - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let decodedFamily = try container.decode(Family.self, forKey: .family) - try self.init(container.decode(String.self, forKey: .value)) - guard family == decodedFamily else { - throw CmxIrohCustomPrivateAddressError.invalidAddress - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(value, forKey: .value) - try container.encode(family, forKey: .family) - } - - private static func canonicalIPv4(_ rawValue: String) -> String? { - var address = in_addr() - guard rawValue.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { - return nil - } - var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) - guard inet_ntop(AF_INET, &address, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { - return nil - } - return Self.string(beforeNullIn: buffer) - } - - private static func canonicalIPv6(_ rawValue: String) -> String? { - var address = in6_addr() - guard rawValue.withCString({ inet_pton(AF_INET6, $0, &address) }) == 1 else { - return nil - } - var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else { - return nil - } - return Self.string(beforeNullIn: buffer) - } - - private static func string(beforeNullIn buffer: [CChar]) -> String { - let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } - return String(decoding: bytes, as: UTF8.self) - } -} - -public enum CmxIrohCustomPrivateAddressError: Error, Equatable, Sendable { - case invalidAddress -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomPrivatePathDraft.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomPrivatePathDraft.swift deleted file mode 100644 index 47cb2fab..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomPrivatePathDraft.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -/// Device-local settings input for one Mac's explicit private addresses. -public struct CmxIrohCustomPrivatePathDraft: Equatable, Sendable { - /// Maximum numeric addresses accepted for one Mac on one device. - public static let maximumAddressCount = 8 - - public let macDeviceID: String - public let macDisplayName: String - public let addresses: [String] - public let isEnabled: Bool - - public init( - macDeviceID: String, - macDisplayName: String, - addresses: [String], - isEnabled: Bool - ) { - self.macDeviceID = macDeviceID - self.macDisplayName = macDisplayName - self.addresses = addresses - self.isEnabled = isEnabled - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomRelayDraft.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomRelayDraft.swift deleted file mode 100644 index b84aac43..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohCustomRelayDraft.swift +++ /dev/null @@ -1,35 +0,0 @@ -/// Authentication a user can request for a custom Iroh relay. -public enum CmxIrohCustomRelayCredentialMode: String, Equatable, Sendable { - /// The relay accepts connections without an application credential. - case none - - /// This device must keep a provider-issued secret in secure storage. - case deviceSecret -} - -/// Editable, non-secret metadata for one custom Iroh relay. -public struct CmxIrohCustomRelayDraft: Equatable, Sendable { - /// Stable account-scoped identifier. Empty when creating a relay. - public let id: String? - public let displayName: String - public let provider: String - public let region: String - public let url: String - public let authMode: CmxIrohCustomRelayCredentialMode - - public init( - id: String? = nil, - displayName: String, - provider: String, - region: String, - url: String, - authMode: CmxIrohCustomRelayCredentialMode - ) { - self.id = id - self.displayName = displayName - self.provider = provider - self.region = region - self.url = url - self.authMode = authMode - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohDebugSettingsControlling.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohDebugSettingsControlling.swift deleted file mode 100644 index 409fe12f..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohDebugSettingsControlling.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Debug-only Iroh controls exposed by a host composition root. -@MainActor -public protocol CmxIrohDebugSettingsControlling: AnyObject { - /// Persists one path constraint and restarts the active Iroh runtime in place. - func setIrohDebugTransportVerificationMode( - _ mode: CmxIrohTransportVerificationMode - ) async throws -} - -public extension CmxIrohDebugSettingsControlling { - /// Compatibility entrypoint for the existing macOS relay-only toggle. - func setIrohDebugRelayOnly(_ enabled: Bool) async throws { - try await setIrohDebugTransportVerificationMode( - enabled ? .relayOnly : .automatic - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohDialPlan.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohDialPlan.swift deleted file mode 100644 index e8445553..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohDialPlan.swift +++ /dev/null @@ -1,19 +0,0 @@ -/// The two ordered attempts for reaching an Iroh peer. -/// -/// Callers must finish or cancel the public/native attempt before starting the -/// private-network fallback. The type intentionally has no flattened hint -/// list, so private routes cannot accidentally enter Iroh's first dial. -public struct CmxIrohDialPlan: Equatable, Sendable { - /// Iroh-native public direct and relay paths used for the first attempt. - public let publicPaths: [CmxIrohPathHint] - /// Active-profile private/LAN paths used only after the first attempt fails. - public let privateFallbackPaths: [CmxIrohPathHint] - - init( - publicPaths: [CmxIrohPathHint], - privateFallbackPaths: [CmxIrohPathHint] - ) { - self.publicPaths = publicPaths - self.privateFallbackPaths = privateFallbackPaths - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohNetworkProfileKey.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohNetworkProfileKey.swift deleted file mode 100644 index 27b5d8fc..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohNetworkProfileKey.swift +++ /dev/null @@ -1,43 +0,0 @@ -/// A provider-qualified private-network profile. -/// -/// The provider is part of the key so equal profile names from Tailscale, a -/// LAN observer, and a custom VPN can never authorize one another's hints. -public struct CmxIrohNetworkProfileKey: Codable, Equatable, Hashable, Sendable { - private enum CodingKeys: String, CodingKey { - case source - case profileID = "profile_id" - } - - /// The provider that owns this profile namespace. - public let source: CmxIrohPathHintSource - /// An opaque account-scoped digest of the provider-local profile. - public let profileID: String - - /// Creates a provider-qualified profile key. - /// - Parameters: - /// - source: The provider that owns the identifier namespace. - /// - profileID: A 32-byte account-scoped digest encoded as canonical - /// lowercase hexadecimal. Human-readable network names must be hashed - /// before constructing this value so discovery cannot disclose them. - /// - Throws: ``CmxIrohNetworkProfileKeyError/invalidProfileID`` when the - /// identifier cannot be represented safely on the wire. - public init(source: CmxIrohPathHintSource, profileID: String) throws { - guard profileID.utf8.count == 64, - profileID.utf8.allSatisfy({ byte in - (48...57).contains(byte) || (97...102).contains(byte) - }) else { - throw CmxIrohNetworkProfileKeyError.invalidProfileID - } - self.source = source - self.profileID = profileID - } - - /// Decodes and validates a provider-qualified profile key. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - source: container.decode(CmxIrohPathHintSource.self, forKey: .source), - profileID: container.decode(String.self, forKey: .profileID) - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohNetworkProfileKeyError.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohNetworkProfileKeyError.swift deleted file mode 100644 index 70aed362..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohNetworkProfileKeyError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Validation failures for provider-qualified network profiles. -public enum CmxIrohNetworkProfileKeyError: Error, Equatable, Sendable { - /// The identifier was not a canonical lowercase-hex 32-byte digest. - case invalidProfileID -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHint+AddressValidation.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHint+AddressValidation.swift deleted file mode 100644 index 3434b38e..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHint+AddressValidation.swift +++ /dev/null @@ -1,410 +0,0 @@ -import Darwin -import Foundation - -private struct CmxIrohPathHintValidator { - let hint: CmxIrohPathHint - - /// Returns `nil` for malformed socket syntax, otherwise whether the IP is - /// allowed as a remote Iroh peer address. - private func directSocketAddressIsAllowed(_ value: String) -> Bool? { - guard value == value.trimmingCharacters(in: .whitespacesAndNewlines), - value.utf8.count <= 80, - !value.contains("/"), - !value.contains("@"), - !value.contains("?") && !value.contains("#") - else { - return nil - } - if value.hasPrefix("[") { - guard let closingBracket = value.firstIndex(of: "]"), - value.index(after: closingBracket) < value.endIndex, - value[value.index(after: closingBracket)] == ":" - else { - return nil - } - let host = String(value[value.index(after: value.startIndex)..<closingBracket]) - let portStart = value.index(closingBracket, offsetBy: 2) - let port = String(value[portStart...]) - guard !host.contains("%"), - let addressIsAllowed = ipv6LiteralIsAllowed(host), - isCanonicalPort(port) - else { - return nil - } - return addressIsAllowed - } - guard let separator = value.lastIndex(of: ":"), - value[..<separator].contains(":") == false - else { - return nil - } - let host = String(value[..<separator]) - let port = String(value[value.index(after: separator)...]) - guard let octets = canonicalIPv4Octets(host), - isCanonicalPort(port) - else { - return nil - } - return ipv4AddressIsAllowed(octets) - } - - private func directSocketAddressIsGloballyRoutable(_ value: String) -> Bool { - if value.hasPrefix("["), - let closingBracket = value.firstIndex(of: "]") { - let host = String(value[value.index(after: value.startIndex)..<closingBracket]) - guard let bytes = ipv6LiteralBytes(host) else { - return false - } - return ipv6AddressIsGloballyRoutable(bytes) - } - guard let separator = value.lastIndex(of: ":"), - let octets = canonicalIPv4Octets(String(value[..<separator])) - else { - return false - } - return ipv4AddressIsGloballyRoutable(octets) - } - - private func canonicalIPv4Octets(_ host: String) -> [UInt8]? { - let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 4 else { - return nil - } - let octets = parts.compactMap { part -> UInt8? in - guard !part.isEmpty, - part.utf8.allSatisfy({ (48...57).contains($0) }), - let value = Int(part), - (0...255).contains(value) - else { - return nil - } - guard String(value) == part else { - return nil - } - return UInt8(value) - } - return octets.count == 4 ? octets : nil - } - - private func ipv4AddressIsAllowed(_ octets: [UInt8]) -> Bool { - guard octets.count == 4 else { - return false - } - if octets[0] == 0 || octets[0] == 127 || (224...255).contains(octets[0]) { - return false - } - // IPv4 link-local addresses need an interface scope just like IPv6 - // link-local addresses. This wire type cannot carry one, so no 169.254/16 - // address is safely dialable after serialization. - if octets[0] == 169 && octets[1] == 254 { - return false - } - return true - } - - private func ipv4AddressIsGloballyRoutable(_ octets: [UInt8]) -> Bool { - guard ipv4AddressIsAllowed(octets) else { - return false - } - let first = octets[0] - let second = octets[1] - let third = octets[2] - if first == 10 - || (first == 100 && (64...127).contains(second)) - || (first == 169 && second == 254) - || (first == 172 && (16...31).contains(second)) - || (first == 192 && second == 168) { - return false - } - if (first == 192 && second == 0 && third == 0) - || (first == 192 && second == 0 && third == 2) - || (first == 192 && second == 88 && third == 99) - || (first == 198 && (second == 18 || second == 19)) - || (first == 198 && second == 51 && third == 100) - || (first == 203 && second == 0 && third == 113) { - return false - } - return true - } - - private func ipv6LiteralIsAllowed(_ host: String) -> Bool? { - guard let bytes = ipv6LiteralBytes(host) else { - return nil - } - return ipv6AddressIsAllowed(bytes) - } - - private func ipv6LiteralBytes(_ host: String) -> [UInt8]? { - var address = in6_addr() - let parsed = host.withCString { pointer in - inet_pton(AF_INET6, pointer, &address) - } - guard parsed == 1 else { - return nil - } - return withUnsafeBytes(of: &address) { Array($0) } - } - - private func ipv6AddressIsAllowed(_ bytes: [UInt8]) -> Bool { - if bytes.allSatisfy({ $0 == 0 }) || bytes == Array(repeating: 0, count: 15) + [1] { - return false - } - if bytes.first == 0xFF { - return false - } - // A serialized remote `%en0` scope is meaningless on the receiving - // device, while an unscoped fe80::/10 address is not dialable. Local - // discovery must construct any scoped link-local address in-process. - if bytes.count == 16, - bytes[0] == 0xFE, - (bytes[1] & 0xC0) == 0x80 { - return false - } - if bytes == [0xFD, 0x00, 0x0E, 0xC2] - + Array(repeating: 0, count: 10) - + [0x02, 0x54] { - return false - } - let ipv4MappedPrefix = Array(repeating: UInt8(0), count: 10) + [0xFF, 0xFF] - if Array(bytes.prefix(12)) == ipv4MappedPrefix { - return ipv4AddressIsAllowed(Array(bytes.suffix(4))) - } - return true - } - - private func ipv6AddressIsGloballyRoutable(_ bytes: [UInt8]) -> Bool { - let ipv4MappedPrefix = Array(repeating: UInt8(0), count: 10) + [0xFF, 0xFF] - if Array(bytes.prefix(12)) == ipv4MappedPrefix { - return ipv4AddressIsGloballyRoutable(Array(bytes.suffix(4))) - } - guard bytes.count == 16, - (bytes[0] & 0xE0) == 0x20 - else { - return false - } - if bytes[0] == 0x20, - bytes[1] == 0x01, - bytes[2] <= 0x01 || (bytes[2] == 0x0D && bytes[3] == 0xB8) { - return false - } - if bytes[0] == 0x20 && bytes[1] == 0x02 { - return false - } - if bytes[0] == 0x3F && bytes[1] == 0xFF && (bytes[2] & 0xF0) == 0 { - return false - } - return true - } - - private func isCanonicalPort(_ port: String) -> Bool { - guard !port.isEmpty, - port.utf8.allSatisfy({ (48...57).contains($0) }), - let value = Int(port), - (1...65_535).contains(value) - else { - return false - } - return String(value) == port - } - - private func isSafeRelayURL(_ value: String) -> Bool { - guard value == value.trimmingCharacters(in: .whitespacesAndNewlines), - value.utf8.count <= 2_048, - value.rangeOfCharacter(from: .whitespacesAndNewlines) == nil, - value.rangeOfCharacter(from: .controlCharacters) == nil, - !value.contains("\\"), - let components = URLComponents(string: value), - components.scheme?.lowercased() == "https", - let host = components.host, - relayHostIsAllowed(host), - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path.isEmpty || components.path == "/" - else { - return false - } - return components.port.map { (1...65_535).contains($0) } ?? true - } - - private func relayHostIsAllowed(_ host: String) -> Bool { - let normalized = host.lowercased() - if let octets = canonicalIPv4Octets(normalized) { - return ipv4AddressIsGloballyRoutable(octets) - } - if normalized.contains(":"), - let bytes = ipv6LiteralBytes(normalized) - { - return ipv6AddressIsGloballyRoutable(bytes) - } - guard normalized.utf8.count <= 253, - !normalized.hasSuffix("."), - !normalized.hasSuffix(".localhost"), - !normalized.hasSuffix(".local"), - !normalized.hasSuffix(".home.arpa") - else { - return false - } - let labels = normalized.split(separator: ".", omittingEmptySubsequences: false) - guard labels.count >= 2, - labels.allSatisfy({ dnsLabelIsAllowed($0) }), - let topLevelLabel = labels.last, - topLevelLabel.utf8.contains(where: { (97...122).contains($0) }) - else { - return false - } - return true - } - - private func dnsLabelIsAllowed(_ label: Substring) -> Bool { - guard !label.isEmpty, - label.utf8.count <= 63, - let first = label.utf8.first, - let last = label.utf8.last, - isASCIILetterOrDigit(first), - isASCIILetterOrDigit(last) - else { - return false - } - return label.utf8.allSatisfy { byte in - isASCIILetterOrDigit(byte) || byte == 45 - } - } - - private func isASCIILetterOrDigit(_ byte: UInt8) -> Bool { - (48...57).contains(byte) || (97...122).contains(byte) - } - - private func isSafeIdentifier( - _ value: String, - maximumUTF8Count: Int - ) -> Bool { - guard !value.isEmpty, - value.utf8.count <= maximumUTF8Count - else { - return false - } - return value.utf8.allSatisfy { byte in - (48...57).contains(byte) - || (65...90).contains(byte) - || (97...122).contains(byte) - || byte == 45 - || byte == 46 - || byte == 58 - || byte == 95 - } - } - - func validate( - requireCurrentPrivateMetadata: Bool, - requireSafeValueShape: Bool - ) throws { - let value = hint.value - let kind = hint.kind - let source = hint.source - let privacyScope = hint.privacyScope - let observedAt = hint.observedAt - let expiresAt = hint.expiresAt - let networkProfile = hint.networkProfile - guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CmxIrohPathHintError.emptyValue - } - if requireSafeValueShape { - switch kind { - case .directAddress: - guard let directAddressIsAllowed = directSocketAddressIsAllowed(value) else { - throw CmxIrohPathHintError.invalidDirectAddress - } - guard directAddressIsAllowed else { - throw CmxIrohPathHintError.forbiddenDirectAddress - } - if privacyScope == .publicInternet, - !directSocketAddressIsGloballyRoutable(value) - { - throw CmxIrohPathHintError.nonGlobalPublicDirectAddress - } - case .relayIdentifier: - guard isSafeIdentifier(value, maximumUTF8Count: 255) else { - throw CmxIrohPathHintError.invalidRelayIdentifier - } - case .relayURL: - guard isSafeRelayURL(value) else { - throw CmxIrohPathHintError.unsafeRelayURL - } - } - } - if kind == .relayIdentifier || kind == .relayURL { - guard source == .native, privacyScope == .publicInternet else { - throw CmxIrohPathHintError.relayHintRequiresNativePublicSource - } - } - switch source { - case .native: - let isInertLegacyPrivateHint = !requireCurrentPrivateMetadata - && observedAt == nil - && expiresAt == nil - && networkProfile == nil - guard privacyScope == .publicInternet || isInertLegacyPrivateHint else { - throw CmxIrohPathHintError.incompatiblePrivacyScope( - source: source, - scope: privacyScope - ) - } - case .lan: - guard privacyScope == .localNetwork else { - throw CmxIrohPathHintError.incompatiblePrivacyScope( - source: source, - scope: privacyScope - ) - } - case .tailscale, .customVPN: - guard privacyScope == .privateNetwork else { - throw CmxIrohPathHintError.incompatiblePrivacyScope( - source: source, - scope: privacyScope - ) - } - } - if privacyScope == .publicInternet { - guard networkProfile == nil else { - throw CmxIrohPathHintError.unexpectedPublicNetworkProfile - } - return - } - guard requireCurrentPrivateMetadata else { - return - } - guard let observedAt else { - throw CmxIrohPathHintError.missingPrivateHintObservation - } - guard let expiresAt else { - throw CmxIrohPathHintError.missingPrivateHintExpiry - } - guard let networkProfile else { - throw CmxIrohPathHintError.missingPrivateHintNetworkProfile - } - guard networkProfile.source == source else { - throw CmxIrohPathHintError.networkProfileSourceMismatch - } - let lifetime = expiresAt.timeIntervalSince(observedAt) - guard lifetime > 0 else { - throw CmxIrohPathHintError.invalidPrivateHintLifetime - } - guard lifetime <= CmxIrohPathHint.maximumPrivateHintTTL else { - throw CmxIrohPathHintError.privateHintTTLExceedsMaximum - } - } -} - -extension CmxIrohPathHint { - func validate( - requireCurrentPrivateMetadata: Bool, - requireSafeValueShape: Bool - ) throws { - try CmxIrohPathHintValidator(hint: self).validate( - requireCurrentPrivateMetadata: requireCurrentPrivateMetadata, - requireSafeValueShape: requireSafeValueShape - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHint.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHint.swift deleted file mode 100644 index 3ada50fc..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHint.swift +++ /dev/null @@ -1,254 +0,0 @@ -import Darwin -import Foundation - -/// A provider-attributed, privacy-scoped address hint for an Iroh peer. -/// -/// Hints influence reachability only. They never establish peer identity or -/// authorize credentials. Non-public hints are fallback-only by construction -/// and newly created private hints must expire. -public struct CmxIrohPathHint: Equatable, Sendable { - /// The longest lifetime accepted for any non-public hint. - public static let maximumPrivateHintTTL: TimeInterval = 60 * 60 - - /// The clock skew tolerated when comparing a provider observation with the - /// local clock. A larger future offset makes the hint inert instead of - /// extending its usable lifetime. - public static let maximumObservationClockSkew: TimeInterval = 5 * 60 - - private enum CodingKeys: String, CodingKey { - case kind - case value - case source - case privacyScope = "privacy_scope" - case observedAt = "observed_at" - case expiresAt = "expires_at" - case networkProfile = "network_profile" - case legacyNetworkProfileID = "network_profile_id" - } - - /// The address form carried by the hint. - public let kind: CmxIrohPathHintKind - /// The socket address, relay identifier, or relay URL. - public let value: String - /// The provider that discovered the hint. - public let source: CmxIrohPathHintSource - /// The network scope in which the hint may be disclosed. - public let privacyScope: CmxIrohPathHintPrivacyScope - /// When the provider last observed this path. - public let observedAt: Date? - /// The time after which the hint must no longer be attempted. - public let expiresAt: Date? - /// The provider-qualified overlay, site, or network profile. - /// - /// This disambiguates overlapping private address spaces. It is routing - /// metadata only and never contributes to peer authentication. - public let networkProfile: CmxIrohNetworkProfileKey? - - /// Creates a validated Iroh path hint. - /// - /// Every non-public hint requires an observation time, an expiry no more - /// than one hour later, and a provider-qualified active-network profile. - /// Older hints missing those fields decode only through the internal inert - /// compatibility path and remain unusable until refreshed. - /// - Parameters: - /// - kind: The address form carried by the hint. - /// - value: The socket address, relay identifier, or relay URL. - /// - source: The provider that discovered the hint. - /// - privacyScope: The narrowest scope in which it may be disclosed. - /// - observedAt: When the provider observed the path. - /// - expiresAt: The time after which the hint must not be attempted. - /// - networkProfile: The provider-qualified active-network profile. - /// - Throws: ``CmxIrohPathHintError`` when the hint violates its invariants. - public init( - kind: CmxIrohPathHintKind, - value: String, - source: CmxIrohPathHintSource, - privacyScope: CmxIrohPathHintPrivacyScope, - observedAt: Date? = nil, - expiresAt: Date? = nil, - networkProfile: CmxIrohNetworkProfileKey? = nil - ) throws { - self.kind = kind - self.value = value - self.source = source - self.privacyScope = privacyScope - self.observedAt = observedAt - self.expiresAt = expiresAt - self.networkProfile = networkProfile - try validate(requireCurrentPrivateMetadata: true, requireSafeValueShape: true) - } - - /// The routing tier derived from privacy scope. - /// - /// Callers cannot promote a private-network address to a primary path. - public var use: CmxIrohPathHintUse { - privacyScope == .publicInternet ? .primary : .fallbackOnly - } - - /// Whether the hint may be attempted at a given time. - /// - /// Legacy private hints without an expiry decode for compatibility but are - /// deliberately inert until a current producer replaces them. - /// - Parameter now: The time against which expiry is checked. - /// - Returns: `true` when the hint is current and usable. - public func isUsable(at now: Date) -> Bool { - guard isSafeForCurrentWireFormat else { - return false - } - if let observedAt, - observedAt > now.addingTimeInterval(Self.maximumObservationClockSkew) { - return false - } - if privacyScope != .publicInternet { - guard let expiresAt, - expiresAt <= now.addingTimeInterval( - Self.maximumPrivateHintTTL + Self.maximumObservationClockSkew - ) else { - return false - } - } - if let expiresAt { - return expiresAt > now - } - return privacyScope == .publicInternet - } - - /// A public-disclosure copy, or `nil` when this hint is private, local, - /// expired, or structurally unsafe. - public func publicDisclosure(at now: Date) -> Self? { - guard privacyScope == .publicInternet, isUsable(at: now) else { - return nil - } - return try? Self( - kind: kind, - value: value, - source: source, - privacyScope: privacyScope, - observedAt: observedAt, - expiresAt: expiresAt, - networkProfile: nil - ) - } - - /// Revalidates structural relationships while tolerating inert legacy data. - func validate() throws { - try validate(requireCurrentPrivateMetadata: false, requireSafeValueShape: false) - } - - /// Whether the hint satisfies the current value, privacy, and expiry rules. - /// - /// Legacy fields may decode without satisfying this predicate so old - /// tickets remain readable, but those hints must not be attempted or - /// re-emitted into a format that would promote them. - public var isSafeForCurrentWireFormat: Bool { - do { - try validate(requireCurrentPrivateMetadata: true, requireSafeValueShape: true) - return true - } catch { - return false - } - } - - /// Builds an inert compatibility hint from the pre-provenance wire fields. - init( - legacyKind kind: CmxIrohPathHintKind, - value: String, - privacyScope: CmxIrohPathHintPrivacyScope - ) { - self.init( - rawKind: kind, - value: value, - source: .native, - privacyScope: privacyScope, - observedAt: nil, - expiresAt: nil, - networkProfile: nil - ) - } - - private init( - rawKind kind: CmxIrohPathHintKind, - value: String, - source: CmxIrohPathHintSource, - privacyScope: CmxIrohPathHintPrivacyScope, - observedAt: Date?, - expiresAt: Date?, - networkProfile: CmxIrohNetworkProfileKey? - ) { - self.kind = kind - self.value = value - self.source = source - self.privacyScope = privacyScope - self.observedAt = observedAt - self.expiresAt = expiresAt - self.networkProfile = networkProfile - } - -} - -extension CmxIrohPathHint: Codable { - /// Decodes a path hint, preserving incomplete legacy private hints as inert - /// compatibility data while validating current wire forms. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let kind = try container.decode(CmxIrohPathHintKind.self, forKey: .kind) - let value = try container.decode(String.self, forKey: .value) - let source = try container.decode(CmxIrohPathHintSource.self, forKey: .source) - let privacyScope = try container.decode(CmxIrohPathHintPrivacyScope.self, forKey: .privacyScope) - let observedAt = try container.decodeIfPresent(Date.self, forKey: .observedAt) - let expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt) - let networkProfile: CmxIrohNetworkProfileKey? - if let current = try container.decodeIfPresent( - CmxIrohNetworkProfileKey.self, - forKey: .networkProfile - ) { - networkProfile = current - } else if let legacyID = try container.decodeIfPresent( - String.self, - forKey: .legacyNetworkProfileID - ) { - networkProfile = try CmxIrohNetworkProfileKey(source: source, profileID: legacyID) - } else { - networkProfile = nil - } - - if privacyScope == .publicInternet - || (observedAt != nil && expiresAt != nil && networkProfile != nil) { - try self.init( - kind: kind, - value: value, - source: source, - privacyScope: privacyScope, - observedAt: observedAt, - expiresAt: expiresAt, - networkProfile: networkProfile - ) - } else { - // Compatibility with the first provenance-aware wire revision. - // Missing freshness/profile metadata remains readable but inert, - // and endpoint encoders prune it instead of re-emitting it. - self.init( - rawKind: kind, - value: value, - source: source, - privacyScope: privacyScope, - observedAt: observedAt, - expiresAt: expiresAt, - networkProfile: networkProfile - ) - try validate() - } - } - - /// Encodes the validated path-hint fields in the current wire form. - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(kind, forKey: .kind) - try container.encode(value, forKey: .value) - try container.encode(source, forKey: .source) - try container.encode(privacyScope, forKey: .privacyScope) - try container.encodeIfPresent(observedAt, forKey: .observedAt) - try container.encodeIfPresent(expiresAt, forKey: .expiresAt) - try container.encodeIfPresent(networkProfile, forKey: .networkProfile) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintError.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintError.swift deleted file mode 100644 index 4682e216..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintError.swift +++ /dev/null @@ -1,36 +0,0 @@ -/// Validation failures for Iroh path hints. -public enum CmxIrohPathHintError: Error, Equatable, Sendable { - /// The hint carried no address or relay value. - case emptyValue - /// The provider and privacy scope describe incompatible networks. - case incompatiblePrivacyScope( - source: CmxIrohPathHintSource, - scope: CmxIrohPathHintPrivacyScope - ) - /// A newly created private hint omitted its required expiry. - case missingPrivateHintExpiry - /// A newly created non-public hint omitted the time it was observed. - case missingPrivateHintObservation - /// A non-public hint's expiry did not follow its observation time. - case invalidPrivateHintLifetime - /// A non-public hint exceeded the maximum one-hour lifetime. - case privateHintTTLExceedsMaximum - /// A non-public hint omitted its provider-qualified network profile. - case missingPrivateHintNetworkProfile - /// A hint used a profile owned by a different provider. - case networkProfileSourceMismatch - /// A public hint carried private-network profile metadata. - case unexpectedPublicNetworkProfile - /// Relay hints must come from Iroh-native public discovery. - case relayHintRequiresNativePublicSource - /// A direct hint was not an IPv4-or-bracketed-IPv6 socket address. - case invalidDirectAddress - /// A direct hint targeted a non-peer address such as loopback or multicast. - case forbiddenDirectAddress - /// A direct hint claimed public scope for a non-globally-routable address. - case nonGlobalPublicDirectAddress - /// A relay identifier contained unsafe or ambiguous characters. - case invalidRelayIdentifier - /// A relay URL was not a root HTTPS URL without credentials or query data. - case unsafeRelayURL -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintKind.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintKind.swift deleted file mode 100644 index 80131d80..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintKind.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// The address form carried by an Iroh path hint. -public enum CmxIrohPathHintKind: String, Codable, Sendable { - /// A socket address that Iroh may try directly. - case directAddress = "direct_address" - /// A legacy relay identifier understood by the Iroh integration. - case relayIdentifier = "relay_identifier" - /// A relay server URL. - case relayURL = "relay_url" -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintPrivacyScope.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintPrivacyScope.swift deleted file mode 100644 index 4fb041a8..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintPrivacyScope.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// The narrowest network scope in which an Iroh path hint may be disclosed. -public enum CmxIrohPathHintPrivacyScope: String, Codable, Sendable { - /// The hint is safe to publish through Internet discovery. - case publicInternet = "public_internet" - /// The hint may be shared only on the current local network. - case localNetwork = "local_network" - /// The hint may be shared only through the user's private network. - case privateNetwork = "private_network" -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintSource.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintSource.swift deleted file mode 100644 index b246cab0..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintSource.swift +++ /dev/null @@ -1,13 +0,0 @@ -/// The provider that discovered an Iroh path hint. -/// -/// Provenance affects privacy and routing policy, never peer authentication. -public enum CmxIrohPathHintSource: String, Codable, Sendable { - /// Iroh's native discovery or relay configuration supplied the hint. - case native - /// Local-link discovery supplied the hint. - case lan - /// Tailscale supplied the hint. - case tailscale - /// A user-configured private-network or VPN provider supplied the hint. - case customVPN = "custom_vpn" -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintUse.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintUse.swift deleted file mode 100644 index 909d8b8f..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPathHintUse.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// The routing tier assigned to an Iroh path hint. -public enum CmxIrohPathHintUse: String, Codable, Sendable { - /// A public Iroh-native path that may be attempted normally. - case primary - /// A private path that may be attempted only after primary paths. - case fallbackOnly = "fallback_only" -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPeerIdentity.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPeerIdentity.swift deleted file mode 100644 index 53b3e6ee..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPeerIdentity.swift +++ /dev/null @@ -1,34 +0,0 @@ -/// The stable cryptographic identity of an Iroh endpoint. -/// -/// This value identifies the peer independently from every address or relay -/// hint used to reach it. A route may change hints without changing identity. -public struct CmxIrohPeerIdentity: Codable, Equatable, Hashable, Sendable { - private enum CodingKeys: String, CodingKey { - case endpointID - } - - /// The Iroh endpoint identifier presented by the route. - public let endpointID: String - - /// Creates an Iroh peer identity from an endpoint identifier. - /// - /// Iroh's canonical display form is exactly 32 bytes encoded as 64 - /// lowercase hexadecimal characters. Other spellings are rejected so one - /// peer cannot acquire multiple persistence or deduplication identities. - /// - Parameter endpointID: The stable Iroh endpoint identifier. - public init(endpointID: String) throws { - guard endpointID.utf8.count == 64, - endpointID.utf8.allSatisfy({ byte in - (48...57).contains(byte) || (97...102).contains(byte) - }) else { - throw CmxIrohPeerIdentityError.nonCanonicalEndpointID - } - self.endpointID = endpointID - } - - /// Decodes and validates a canonical Iroh EndpointID. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init(endpointID: container.decode(String.self, forKey: .endpointID)) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPeerIdentityError.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPeerIdentityError.swift deleted file mode 100644 index f81ad605..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPeerIdentityError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Validation failures for Iroh peer identity values. -public enum CmxIrohPeerIdentityError: Error, Equatable, Sendable { - /// The value was not exactly 64 lowercase hexadecimal characters. - case nonCanonicalEndpointID -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPrivatePathSynthesizer.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPrivatePathSynthesizer.swift deleted file mode 100644 index ba9f4ae5..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohPrivatePathSynthesizer.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation - -private struct CmxIrohPathHintMergeKey: Hashable { - let kind: String - let value: String - let source: String - let networkProfile: CmxIrohNetworkProfileKey? - - init(_ hint: CmxIrohPathHint) { - kind = hint.kind.rawValue - value = hint.value - source = hint.source.rawValue - networkProfile = hint.networkProfile - } -} - -public extension CmxIrohNetworkProfileKey { - /// The strongest Tailscale profile iOS can prove without a provider API. - /// - /// Apple exposes the active packet tunnel and its assigned addresses, but - /// not Tailscale's tailnet identifier. This profile therefore means "a - /// Tailscale tunnel is active on this device." It is routing metadata only; - /// the Iroh EndpointID remains the peer-authentication authority. - static let activeTailscaleTunnel: CmxIrohNetworkProfileKey = { - do { - return try CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: "42e59eea27473bde00430ca3d4a0f34a372713f0b90d46ee1ab2802c6d668979" - ) - } catch { - preconditionFailure("The built-in Tailscale network profile is invalid: \(error)") - } - }() -} - -public extension CmxAttachRoute { - /// Adds short-lived Tailscale addresses to every existing Iroh route. - /// - /// Private paths never contribute identity or authorization. They are - /// attached only to an existing Iroh EndpointID and remain fallback-only, - /// so a wrong or stale address can only fail Iroh's authenticated handshake. - /// Numeric Tailscale validation rejects LAN, public, MagicDNS, service, and - /// generic host routes. The original raw routes remain in the returned set - /// for rolling compatibility, but callers continue pinning connection - /// attempts to Iroh whenever an Iroh route exists. - static func addingIrohPrivatePaths( - to routes: [CmxAttachRoute], - observedAt: Date - ) -> [CmxAttachRoute] { - let maximumHintCount = CmxAttachEndpoint.maximumIrohPathHintCount - var candidateKeys: Set<CmxIrohPathHintMergeKey> = [] - var candidates: [(key: CmxIrohPathHintMergeKey, hint: CmxIrohPathHint)] = [] - candidates.reserveCapacity(maximumHintCount) - for route in routes where candidates.count < maximumHintCount { - guard let hint = route.irohTailscalePathHint(observedAt: observedAt) else { - continue - } - let key = CmxIrohPathHintMergeKey(hint) - guard candidateKeys.insert(key).inserted else { continue } - candidates.append((key, hint)) - } - guard !candidates.isEmpty else { return routes } - - return routes.map { route in - guard route.kind == .iroh, - case let .peer(identity, pathHints) = route.endpoint else { - return route - } - - let usableHints = pathHints.filter { $0.isUsable(at: observedAt) } - var existingCounts: [CmxIrohPathHintMergeKey: Int] = [:] - existingCounts.reserveCapacity(usableHints.count) - for hint in usableHints { - existingCounts[CmxIrohPathHintMergeKey(hint), default: 0] += 1 - } - - var removedExistingKeys: Set<CmxIrohPathHintMergeKey> = [] - var appendedCandidates: [CmxIrohPathHint] = [] - appendedCandidates.reserveCapacity(candidates.count) - var mergedCount = usableHints.count - for candidate in candidates { - if let removedCount = existingCounts[candidate.key] { - removedExistingKeys.insert(candidate.key) - mergedCount -= removedCount - } - guard mergedCount < maximumHintCount else { continue } - appendedCandidates.append(candidate.hint) - mergedCount += 1 - } - - var hints = usableHints.filter { - !removedExistingKeys.contains(CmxIrohPathHintMergeKey($0)) - } - hints.append(contentsOf: appendedCandidates) - return (try? CmxAttachRoute( - id: route.id, - kind: route.kind, - endpoint: .peer(identity: identity, pathHints: hints), - priority: route.priority - )) ?? route - } - } - - /// Creates one fallback-only Iroh hint from a canonical Tailscale peer. - func irohTailscalePathHint(observedAt: Date) -> CmxIrohPathHint? { - guard kind == .tailscale, - case let .hostPort(host, port) = endpoint, - let address = CmxTailscalePeerAddress(host) else { - return nil - } - let socketAddress: String - switch address.family { - case .ipv4: - socketAddress = "\(address.value):\(port)" - case .ipv6: - socketAddress = "[\(address.value)]:\(port)" - } - return try? CmxIrohPathHint( - kind: .directAddress, - value: socketAddress, - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: observedAt, - expiresAt: observedAt.addingTimeInterval( - CmxIrohPathHint.maximumPrivateHintTTL - ), - networkProfile: .activeTailscaleTunnel - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayPreferenceDraft.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayPreferenceDraft.swift deleted file mode 100644 index fabc1d11..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayPreferenceDraft.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// A user-visible relay preference shared by the macOS and iOS settings surfaces. -public enum CmxIrohRelayPreferenceDraft: Equatable, Sendable { - /// Allow every relay in the signed cmux catalog and let Iroh choose. - case automatic - - /// Allow only the selected stable relay identifiers from the signed catalog. - case managed(Set<String>) - - /// Disable managed relays and use the account's custom relay definitions. - case custom - - /// Returns this preference after enforcing the cross-platform UI boundary. - /// Controllers must call this before persistence so an alternate settings - /// entrypoint cannot create an empty or oversized managed selection. - public func validated() throws -> Self { - guard case let .managed(ids) = self else { return self } - guard (1 ... 16).contains(ids.count), ids.allSatisfy(Self.isSafeRelayID) else { - throw CmxIrohRelayPreferenceDraftError.invalidManagedSelection - } - return self - } - - private static func isSafeRelayID(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayPreferenceDraftError.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayPreferenceDraftError.swift deleted file mode 100644 index b9d0211a..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayPreferenceDraftError.swift +++ /dev/null @@ -1,4 +0,0 @@ -/// Validation failures for a user-visible relay preference draft. -public enum CmxIrohRelayPreferenceDraftError: Error, Equatable, Sendable { - case invalidManagedSelection -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayTestResult.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayTestResult.swift deleted file mode 100644 index 70fd8e7c..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohRelayTestResult.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Non-secret result of probing a configured custom relay. -public enum CmxIrohRelayTestResult: Equatable, Sendable { - /// The relay accepted a protocol connection. - case reachable(latencyMilliseconds: Int?) - - /// The relay could not be reached or rejected the configured credential. - case failed - - /// The relay definition or its required device credential is incomplete. - case incomplete -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSelectedTransportPath.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSelectedTransportPath.swift deleted file mode 100644 index d031491d..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSelectedTransportPath.swift +++ /dev/null @@ -1,29 +0,0 @@ -/// A redacted description of the live Iroh path safe for application settings. -/// -/// The value deliberately excludes IP addresses, ports, relay URLs, and Iroh -/// path identifiers. Relay labels come only from the verified effective policy. -public enum CmxIrohSelectedTransportPath: Equatable, Sendable { - /// No attributable live connection path is available. - case unavailable - - /// Application traffic is using a direct public peer-to-peer path. - case direct - - /// Application traffic is using a private or local network path. - case privateNetwork - - /// Application traffic is using a relay from the signed managed catalog. - /// - /// - Parameters: - /// - provider: The provider label from the signed policy. - /// - region: The region label from the signed policy. - case managedRelay(provider: String, region: String) - - /// Application traffic is using an account-defined custom relay. - /// - /// - Parameters: - /// - displayName: The user-supplied display name, or stable relay ID. - /// - provider: The user-supplied provider label. - /// - region: The user-supplied region label. - case customRelay(displayName: String, provider: String, region: String) -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSettingsControlling.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSettingsControlling.swift deleted file mode 100644 index 87b08240..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSettingsControlling.swift +++ /dev/null @@ -1,68 +0,0 @@ -public import Foundation - -/// Cross-platform settings boundary implemented by each app's Iroh composition root. -@MainActor -public protocol CmxIrohSettingsControlling: AnyObject { - /// Returns a credential-free snapshot suitable for display and diagnostics. - func irohSettingsSnapshot() async -> CmxIrohSettingsSnapshot - - /// Emits snapshot changes without polling. - func irohSettingsUpdates() -> AsyncStream<CmxIrohSettingsSnapshot> - - /// Persists the account-level relay preference and safely rebuilds the endpoint. - func setIrohRelayPreference(_ preference: CmxIrohRelayPreferenceDraft) async throws - - /// Creates or updates account-visible custom relay metadata and a device-local secret. - func upsertIrohCustomRelay( - _ relay: CmxIrohCustomRelayDraft, - deviceSecret: String? - ) async throws - - /// Removes custom relay metadata and erases this device's associated secret. - func removeIrohCustomRelay(id: String) async throws - - /// Probes one custom relay without changing the active preference. - func testIrohCustomRelay(id: String) async -> CmxIrohRelayTestResult - - /// Persists one device-local custom private-path configuration. - func upsertIrohCustomPrivatePath(_ path: CmxIrohCustomPrivatePathDraft) async throws - - /// Removes this device's custom private paths for one Mac. - func removeIrohCustomPrivatePath(macDeviceID: String) async throws - - /// Fetches the latest signed fleet and account preference. - func refreshIrohSettings() async - - /// Returns the bounded, credential-free connection timeline for this app process. - func irohDiagnosticReport() async -> DiagnosticReport - - /// Exports the same bounded report without terminal contents or network identities. - func exportIrohDiagnosticReport() async -> Data - - /// Erases the in-memory connection timeline and rotates its report session. - func clearIrohDiagnosticReport() async -} - -public extension CmxIrohSettingsControlling { - func upsertIrohCustomPrivatePath(_ path: CmxIrohCustomPrivatePathDraft) async throws { - throw CmxIrohSettingsControlError.unsupported - } - - func removeIrohCustomPrivatePath(macDeviceID: String) async throws { - throw CmxIrohSettingsControlError.unsupported - } - - func irohDiagnosticReport() async -> DiagnosticReport { - .empty - } - - func exportIrohDiagnosticReport() async -> Data { - Data() - } - - func clearIrohDiagnosticReport() async {} -} - -public enum CmxIrohSettingsControlError: Error, Equatable, Sendable { - case unsupported -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSettingsSnapshot.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSettingsSnapshot.swift deleted file mode 100644 index c61da3ae..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohSettingsSnapshot.swift +++ /dev/null @@ -1,181 +0,0 @@ -public import Foundation - -/// Immutable, credential-free state rendered by Iroh settings on macOS and iOS. -public struct CmxIrohSettingsSnapshot: Equatable, Sendable { - public enum RuntimeStatus: Equatable, Sendable { - case inactive - case starting - /// Endpoint is active, but no live peer path is currently attributable. - case active - case direct - case relayed(provider: String, region: String) - case privateNetwork(displayName: String) - case degraded - - /// Creates an active runtime status from one coordinate-free path. - /// - /// - Parameter path: The redacted selected transport path. - public init(activePath path: CmxIrohSelectedTransportPath) { - switch path { - case .unavailable: - self = .active - case .direct: - self = .direct - case .privateNetwork: - self = .privateNetwork(displayName: "") - case let .managedRelay(provider, region): - self = .relayed(provider: provider, region: region) - case let .customRelay(_, provider, region): - self = .relayed(provider: provider, region: region) - } - } - } - - public enum PolicySource: Equatable, Sendable { - case server - case cached - case unavailable - } - - public enum CredentialState: Equatable, Sendable { - case notRequired - case configured - case missing - case unavailable - } - - public struct ManagedRelay: Identifiable, Equatable, Sendable { - public let id: String - public let provider: String - public let region: String - public let url: String - public let isSelected: Bool - - public init(id: String, provider: String, region: String, url: String, isSelected: Bool) { - self.id = id - self.provider = provider - self.region = region - self.url = url - self.isSelected = isSelected - } - } - - public struct CustomRelay: Identifiable, Equatable, Sendable { - public let id: String - public let displayName: String - public let provider: String - public let region: String - public let url: String - public let authMode: CmxIrohCustomRelayCredentialMode - public let credentialState: CredentialState - - public init( - id: String, - displayName: String, - provider: String, - region: String, - url: String, - authMode: CmxIrohCustomRelayCredentialMode, - credentialState: CredentialState - ) { - self.id = id - self.displayName = displayName - self.provider = provider - self.region = region - self.url = url - self.authMode = authMode - self.credentialState = credentialState - } - } - - /// A broker-authenticated Mac available for device-local path settings. - public struct PrivateNetworkMac: Identifiable, Equatable, Sendable { - public let id: String - public let displayName: String - - public init(id: String, displayName: String) { - self.id = id - self.displayName = displayName - } - } - - /// One device-local, per-Mac custom private-path configuration. - public struct CustomPrivateNetwork: Identifiable, Equatable, Sendable { - public var id: String { macDeviceID } - public let macDeviceID: String - public let macDisplayName: String - public let addresses: [String] - public let isEnabled: Bool - - public init( - macDeviceID: String, - macDisplayName: String, - addresses: [String], - isEnabled: Bool - ) { - self.macDeviceID = macDeviceID - self.macDisplayName = macDisplayName - self.addresses = addresses - self.isEnabled = isEnabled - } - } - - public let runtimeStatus: RuntimeStatus - /// Redacted selected-path attribution, independent from lifecycle status. - public let selectedTransportPath: CmxIrohSelectedTransportPath - public let preference: CmxIrohRelayPreferenceDraft - public let managedRelays: [ManagedRelay] - public let customRelays: [CustomRelay] - public let privateNetworkMacs: [PrivateNetworkMac] - public let customPrivateNetworks: [CustomPrivateNetwork] - public let policySource: PolicySource - public let policySequence: Int64? - public let policyExpiresAt: Date? - public let staleRelayIDs: Set<String> - public let failureDescription: String? - /// Debug-only path constraint, or `nil` when the current app cannot control it. - public let debugTransportVerificationMode: CmxIrohTransportVerificationMode? - - /// Compatibility projection for the existing macOS relay-only toggle. - public var debugRelayOnlyEnabled: Bool? { - debugTransportVerificationMode.map { $0 == .relayOnly } - } - - public init( - runtimeStatus: RuntimeStatus, - selectedTransportPath: CmxIrohSelectedTransportPath = .unavailable, - preference: CmxIrohRelayPreferenceDraft, - managedRelays: [ManagedRelay], - customRelays: [CustomRelay], - privateNetworkMacs: [PrivateNetworkMac] = [], - customPrivateNetworks: [CustomPrivateNetwork] = [], - policySource: PolicySource, - policySequence: Int64? = nil, - policyExpiresAt: Date? = nil, - staleRelayIDs: Set<String> = [], - failureDescription: String? = nil, - debugTransportVerificationMode: CmxIrohTransportVerificationMode? = nil - ) { - self.runtimeStatus = runtimeStatus - self.selectedTransportPath = selectedTransportPath - self.preference = preference - self.managedRelays = managedRelays - self.customRelays = customRelays - self.privateNetworkMacs = privateNetworkMacs - self.customPrivateNetworks = customPrivateNetworks - self.policySource = policySource - self.policySequence = policySequence - self.policyExpiresAt = policyExpiresAt - self.staleRelayIDs = staleRelayIDs - self.failureDescription = failureDescription - self.debugTransportVerificationMode = debugTransportVerificationMode - } - - public static let unavailable = CmxIrohSettingsSnapshot( - runtimeStatus: .inactive, - preference: .automatic, - managedRelays: [], - customRelays: [], - policySource: .unavailable - ) -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohTransportPolicy.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohTransportPolicy.swift deleted file mode 100644 index e52afe43..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohTransportPolicy.swift +++ /dev/null @@ -1,178 +0,0 @@ -import Foundation - -extension CmxAttachEndpoint { - /// Creates an Iroh endpoint from the legacy peer fields. - /// - /// This compatibility constructor preserves existing source and wire - /// producers while new code moves to ``peer(identity:pathHints:)``. - /// Legacy direct addresses have no provenance or expiry, so they decode as - /// private, fallback-only, and unusable until refreshed by a current source. - /// - Parameters: - /// - id: The Iroh EndpointID. - /// - relayHint: The optional legacy relay identifier. - /// - directAddrs: Legacy direct socket addresses. - /// - relayURL: The optional relay URL. - /// - Returns: A peer endpoint with identity separated from path hints. - public static func peer( - id: String, - relayHint: String?, - directAddrs: [String], - relayURL: String? - ) throws -> Self { - var pathHints: [CmxIrohPathHint] = [] - if let relayHint { - pathHints.append(CmxIrohPathHint( - legacyKind: .relayIdentifier, - value: relayHint, - privacyScope: .publicInternet - )) - } - pathHints.append(contentsOf: directAddrs.map { address in - CmxIrohPathHint( - legacyKind: .directAddress, - value: address, - privacyScope: .privateNetwork - ) - }) - if let relayURL { - pathHints.append(CmxIrohPathHint( - legacyKind: .relayURL, - value: relayURL, - privacyScope: .publicInternet - )) - } - return .peer( - identity: try CmxIrohPeerIdentity(endpointID: id), - pathHints: pathHints - ) - } - - /// The Iroh identity carried by a peer endpoint, independent of its hints. - public var irohPeerIdentity: CmxIrohPeerIdentity? { - guard case let .peer(identity, _) = self else { - return nil - } - return identity - } - - /// Builds the explicit two-attempt Iroh dial plan. - /// - /// A profile-scoped hint is omitted unless its overlay/site/profile is - /// currently active, preventing an overlapping private address from being - /// attempted on the wrong network. - /// - Parameters: - /// - now: The time against which hint expiry is checked. - /// - managedRelayURLs: The exact relay URLs configured by cmux. Relay - /// hints outside this set and legacy relay identifiers are excluded. - /// - activeNetworkProfiles: Locally verified provider-qualified profiles. - /// - Returns: A two-phase plan for peer endpoints, otherwise `nil`. - public func irohDialPlan( - at now: Date, - managedRelayURLs: Set<String>, - activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> = [] - ) -> CmxIrohDialPlan? { - guard case let .peer(_, pathHints) = self else { - return nil - } - let publicPaths = pathHints.filter { hint in - guard hint.privacyScope == .publicInternet, - hint.isUsable(at: now) else { - return false - } - switch hint.kind { - case .directAddress: - return true - case .relayURL: - return managedRelayURLs.contains(hint.value) - case .relayIdentifier: - return false - } - } - let privateFallbackPaths = pathHints.filter { hint in - guard hint.privacyScope != .publicInternet, - hint.isUsable(at: now), - let networkProfile = hint.networkProfile else { - return false - } - return activeNetworkProfiles.contains(networkProfile) - } - return CmxIrohDialPlan( - publicPaths: publicPaths, - privateFallbackPaths: privateFallbackPaths - ) - } -} - -extension CmxAttachEndpoint { - /// Returns a copy whose Iroh hints are current and permitted at a - /// serialization boundary. - fileprivate func disclosed( - for disclosure: CmxAttachRouteDisclosure, - at now: Date - ) -> Self { - guard case let .peer(identity, pathHints) = self else { - return self - } - let disclosedHints: [CmxIrohPathHint] - switch disclosure { - case .authenticated: - disclosedHints = pathHints.filter { $0.isUsable(at: now) } - case .cloudRendezvous, .pairedMacCloudBackup: - disclosedHints = pathHints.compactMap { hint in - guard hint.kind == .relayURL else { return nil } - return hint.publicDisclosure(at: now) - } - case .publicStatus, .pairingQRCode: - disclosedHints = [] - } - return .peer(identity: identity, pathHints: disclosedHints) - } -} - -extension CmxAttachRoute { - /// Returns the route shape permitted at a serialization boundary. - /// - /// Unauthenticated status exposes no attach routes. Cloud rendezvous, - /// pairing QR, and paired-Mac backup keep only the route data permitted by - /// their stricter disclosure policies. - public func disclosed( - for disclosure: CmxAttachRouteDisclosure, - at now: Date - ) -> Self? { - if disclosure == .publicStatus { - return nil - } - return try? Self( - id: id, - kind: kind, - endpoint: endpoint.disclosed(for: disclosure, at: now), - priority: priority - ) - } -} - -extension CmxAttachTicket { - /// Returns a ticket whose routes are safe for an authenticated transport. - /// - /// Pairing QR and public-status payloads intentionally have different - /// field-level disclosure rules, so they must not use this copy operation. - public func authenticatedDisclosure(at now: Date) throws -> Self { - try Self( - version: version, - workspaceID: workspaceID, - terminalID: terminalID, - macDeviceID: macDeviceID, - macDisplayName: macDisplayName, - macUserEmail: macUserEmail, - macUserID: macUserID, - macPairingCompatibilityVersion: macPairingCompatibilityVersion, - macAppVersion: macAppVersion, - macAppBuild: macAppBuild, - routes: routes.compactMap { - $0.disclosed(for: .authenticated, at: now) - }, - expiresAt: expiresAt, - authToken: authToken - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohTransportVerificationMode.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohTransportVerificationMode.swift deleted file mode 100644 index 1655bf38..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxIrohTransportVerificationMode.swift +++ /dev/null @@ -1,19 +0,0 @@ -/// Transport policy used by debug builds to verify each supported Iroh path class. -public enum CmxIrohTransportVerificationMode: String, CaseIterable, Equatable, Sendable { - /// Uses configured relays while allowing authenticated direct-path activation. - case automatic - - /// Uses configured relays and prevents authenticated direct-path activation. - case relayOnly - - /// Disables Iroh relay listening and dialing while retaining direct paths. - case directOnly - - /// Shared defaults key used independently by the macOS and iOS debug apps. - public static let debugDefaultsKey = "cmux.iroh.debug.transport-mode" - - /// Whether an admitted connection may activate and migrate to direct paths. - public var allowsNATTraversalAfterAdmission: Bool { - self != .relayOnly - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift deleted file mode 100644 index f2143781..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyPrivateNetworkPairingCode.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -/// Encodes the full-key v1 pairing payload required by released iOS clients -/// that predate the compact ticket and bare-route grammars. -public struct CmxLegacyPrivateNetworkPairingCode: Sendable { - /// The compatibility payload is non-authorizing, so its synthetic expiry - /// only prevents historical decoders from rejecting a displayed code. - private static let compatibilityExpiry = Date(timeIntervalSince1970: 4_102_444_800) - - /// Creates the stateless compatibility encoder. - public init() {} - - /// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the - /// ticket has no Tailscale route to disclose. - public func encode(_ ticket: CmxAttachTicket) throws -> URL? { - let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale } - guard !tailscaleRoutes.isEmpty else { return nil } - - let legacyTicket = try CmxAttachTicket( - version: ticket.version, - workspaceID: ticket.workspaceID, - terminalID: ticket.terminalID, - macDeviceID: ticket.macDeviceID, - macDisplayName: ticket.macDisplayName, - macUserEmail: nil, - macUserID: ticket.macUserID, - macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion, - macAppVersion: ticket.macAppVersion, - macAppBuild: ticket.macAppBuild, - routes: tailscaleRoutes, - expiresAt: Self.compatibilityExpiry, - authToken: nil - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let payload = base64URLEncode(try encoder.encode(legacyTicket)) - return URL( - string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)" - ) - } - - private func base64URLEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyTailscaleAuthorizationEvidence.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyTailscaleAuthorizationEvidence.swift deleted file mode 100644 index 7ef7ad70..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLegacyTailscaleAuthorizationEvidence.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -/// Invalid input for a persisted pre-Iroh Tailscale compatibility grant. -public enum CmxLegacyTailscaleAuthorizationEvidenceError: Error, Equatable, Sendable { - /// The Mac device identifier was empty or surrounded by whitespace. - case invalidMacDeviceID - /// The host was not a numeric Tailscale peer address. - case invalidHost - /// The port fell outside `1...65535`. - case invalidPort(Int) -} - -/// A narrow capability allowing one pre-Iroh pairing to keep using its exact -/// plaintext Tailscale route while both sides move through a staggered update. -/// -/// This value is transport evidence, not route discovery. It authorizes only -/// one canonical Mac device ID, numeric Tailscale peer address, and TCP port. -public struct CmxLegacyTailscaleAuthorizationEvidence: Equatable, Sendable { - /// The canonical paired Mac device identifier. - public let macDeviceID: String - /// The canonical numeric Tailscale peer address. - public let host: String - /// The exact legacy mobile listener port. - public let port: Int - - /// Validates and canonicalizes one persisted compatibility grant. - public init(macDeviceID: String, host: String, port: Int) throws { - let trimmedDeviceID = macDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedDeviceID.isEmpty, trimmedDeviceID == macDeviceID else { - throw CmxLegacyTailscaleAuthorizationEvidenceError.invalidMacDeviceID - } - guard let peerAddress = CmxTailscalePeerAddress(host) else { - throw CmxLegacyTailscaleAuthorizationEvidenceError.invalidHost - } - guard (1 ... 65_535).contains(port) else { - throw CmxLegacyTailscaleAuthorizationEvidenceError.invalidPort(port) - } - - self.macDeviceID = cmxCanonicalDeviceID(macDeviceID) - self.host = peerAddress.value - self.port = port - } - - /// Whether a request still names the exact peer captured by this grant. - public func authorizes(macDeviceID: String?, host: String, port: Int) -> Bool { - guard let macDeviceID, - cmxCanonicalDeviceID(macDeviceID) == self.macDeviceID, - let peerAddress = CmxTailscalePeerAddress(host) else { - return false - } - return peerAddress.value == self.host && port == self.port - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLoopbackHost.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLoopbackHost.swift deleted file mode 100644 index 3b9910e9..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxLoopbackHost.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Darwin -import Foundation - -/// Single source of truth for "is this host loopback?" across the mobile -/// stack. -/// -/// Pairing policy depends on this answer in two opposite directions, so it -/// must come from one place: -/// - Loopback is the *most* trusted channel for manual dev pairing (it never -/// leaves the machine, so it may carry the Stack bearer token). -/// - Loopback is *forbidden* in anything that arrives by QR or deep link: a -/// scanned code pointing at `127.0.0.1` would make the phone dial itself, -/// so the phone rejects it outright and the Mac never mints one. -/// -/// Because this is a trust boundary, classification is byte-based, not -/// string-pattern-based: hosts are parsed with the same libc semantics the -/// dialer's resolver applies (`inet_aton` for IPv4 names, so legacy spellings -/// like `127.1`, `0x7f.0.0.1`, and `2130706433` classify exactly as they -/// dial; `inet_pton` for IPv6, so every compressed/uncompressed/mixed -/// spelling of one address classifies identically). Anything that dials the -/// local machine counts: `127.0.0.0/8`, the unspecified `0.0.0.0/8` (a TCP -/// connect to it lands on loopback), IPv6 `::1` and `::`, and IPv4-mapped or -/// IPv4-compatible IPv6 forms embedding those ranges. `localhost` and -/// `*.localhost` names match with or without the trailing root dot. -public struct CmxLoopbackHost: Sendable { - /// Creates the classifier. It is stateless: construct one inline wherever - /// a loopback decision is needed; every instance applies the same rules. - public init() {} - - /// Whether `host` names the local machine. - /// - Parameter host: A bare host string (IPv4, IPv6 with or without - /// brackets or a zone index, or a DNS name). - public func matches(_ host: String) -> Bool { - var normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if normalized.hasPrefix("["), normalized.hasSuffix("]"), normalized.count > 2 { - normalized = String(normalized.dropFirst().dropLast()) - } - // A single trailing root dot is the fully-qualified spelling of the - // same name (`localhost.`) and must classify identically. - if normalized.hasSuffix("."), !normalized.dropLast().isEmpty { - normalized = String(normalized.dropLast()) - } - guard !normalized.isEmpty else { - return false - } - if normalized == "localhost" || normalized.hasSuffix(".localhost") { - return true - } - if let firstIPv4Octet = ipv4FirstOctet(normalized) { - return isSelfDialingIPv4FirstOctet(firstIPv4Octet) - } - if let ipv6 = ipv6Bytes(normalized) { - return isSelfDialingIPv6(ipv6) - } - return false - } - - /// Whether `endpoint` dials a loopback host. - /// - Parameter endpoint: The attach endpoint to classify. - public func matches(_ endpoint: CmxAttachEndpoint) -> Bool { - guard case let .hostPort(host, _) = endpoint else { - return false - } - return matches(host) - } - - /// Whether `route` is a loopback route: either declared as the - /// `debugLoopback` transport kind or dialing a loopback host. - /// - Parameter route: The attach route to classify. - public func matches(_ route: CmxAttachRoute) -> Bool { - route.kind == .debugLoopback || matches(route.endpoint) - } -} - -private extension CmxLoopbackHost { - /// The first octet of `host` parsed with `inet_aton` semantics (the same - /// numeric forms the resolver accepts for name-looking hosts: dotted - /// quad, fewer-than-four parts, octal, hex, and single 32-bit decimals), - /// or `nil` when the host is not an IPv4 literal in any of those forms. - func ipv4FirstOctet(_ host: String) -> UInt8? { - var address = in_addr() - guard inet_aton(host, &address) != 0 else { - return nil - } - // `s_addr` is in network byte order; the first octet is the - // highest-order byte of the big-endian value. - return UInt8(truncatingIfNeeded: UInt32(bigEndian: address.s_addr) >> 24) - } - - /// `127.0.0.0/8` is loopback; `0.0.0.0/8` (the unspecified range) is - /// included because a TCP connect to it lands on the local machine too. - func isSelfDialingIPv4FirstOctet(_ firstOctet: UInt8) -> Bool { - firstOctet == 127 || firstOctet == 0 - } - - /// The 16 address bytes of `host` parsed with `inet_pton`, or `nil` when - /// it is not an IPv6 literal. A zone index suffix (`%lo0`) is stripped - /// first: the zone scopes which interface dials, not which address. - func ipv6Bytes(_ host: String) -> [UInt8]? { - var literal = host - if let zoneSeparator = literal.firstIndex(of: "%") { - literal = String(literal[..<zoneSeparator]) - } - var address = in6_addr() - guard inet_pton(AF_INET6, literal, &address) == 1 else { - return nil - } - return withUnsafeBytes(of: address) { Array($0) } - } - - /// Whether the 16 IPv6 address bytes name the local machine (`::1`, `::`, - /// or an IPv4-mapped/IPv4-compatible form embedding a self-dialing range). - func isSelfDialingIPv6(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 16 else { - return false - } - // `::1` (loopback) and `::` (unspecified; connects locally like - // 0.0.0.0): first 15 bytes zero, last byte 0 or 1. - if bytes[0..<15].allSatisfy({ $0 == 0 }), bytes[15] <= 1 { - return true - } - // IPv4-mapped (`::ffff:a.b.c.d`) and the deprecated IPv4-compatible - // (`::a.b.c.d`) forms: classify by the embedded IPv4 first octet. - let prefixIsZero = bytes[0..<10].allSatisfy { $0 == 0 } - let isMapped = prefixIsZero && bytes[10] == 0xFF && bytes[11] == 0xFF - let isCompatible = prefixIsZero && bytes[10] == 0 && bytes[11] == 0 - if isMapped || isCompatible { - return isSelfDialingIPv4FirstOctet(bytes[12]) - } - return false - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxManualPairingEntry.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxManualPairingEntry.swift deleted file mode 100644 index ba26f278..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxManualPairingEntry.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Darwin -import Foundation - -/// The single `host` + `port` the Mac pairing window offers for manual entry -/// (the "Copy IP" / "Copy Port" buttons next to the QR code). -/// -/// Selection mirrors the QR's trust rules and the phone's manual-entry needs: -/// only routes a phone can actually dial qualify (loopback never does, by the -/// shared ``CmxLoopbackHost`` classifier), Tailscale routes are preferred, and -/// among them a numeric IP literal beats a MagicDNS name because a typed IP -/// works even when the phone's DNS is not pointed at the tailnet. Ties fall -/// back to the Mac's own route priority order. -public struct CmxManualPairingEntry: Equatable, Sendable { - /// The address the user types into the phone's host field. - public let host: String - /// The port the user types into the phone's port field. - public let port: Int - - /// Creates a manual-entry pair. - /// - Parameters: - /// - host: The address for the phone's host field. - /// - port: The port for the phone's port field. - public init(host: String, port: Int) { - self.host = host - self.port = port - } - - /// The best manual-entry candidate among `routes`, or `nil` when no route - /// is phone-dialable (no non-loopback `host:port` route at all). - public static func best(in routes: [CmxAttachRoute]) -> CmxManualPairingEntry? { - let candidates = routes - .filter { !CmxLoopbackHost().matches($0) } - .compactMap { route -> (route: CmxAttachRoute, entry: CmxManualPairingEntry)? in - guard case let .hostPort(host, port) = route.endpoint else { - return nil - } - return (route, CmxManualPairingEntry(host: host, port: port)) - } - .sorted { $0.route.priority < $1.route.priority } - let preferred = candidates.filter { $0.route.kind == .tailscale } - let pool = preferred.isEmpty ? candidates : preferred - let pick = pool.first { isIPLiteral($0.entry.host) } ?? pool.first - return pick?.entry - } -} -private extension CmxManualPairingEntry { - /// Whether `host` is a strict numeric IP literal (dotted-quad IPv4 or any - /// IPv6 spelling). Used only as a preference signal, not a trust boundary. - static func isIPLiteral(_ host: String) -> Bool { - var ipv4 = in_addr() - if inet_pton(AF_INET, host, &ipv4) == 1 { - return true - } - var ipv6 = in6_addr() - return inet_pton(AF_INET6, host, &ipv6) == 1 - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift deleted file mode 100644 index 29e20066..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRBitmap.swift +++ /dev/null @@ -1,49 +0,0 @@ -import CoreGraphics -import CoreImage -import CoreImage.CIFilterBuiltins -import Foundation - -/// Renders a pairing payload string as a scanner-friendly QR bitmap. -/// -/// The rendering half of the pairing-QR domain (``CmxPairingQRCode`` is the -/// payload half). The output is one pixel per module, pure black on pure -/// white, with the full ISO/IEC 18004 quiet zone baked into the bitmap, so -/// the white margin scales with the code no matter how the host view lays it -/// out and stays white regardless of app theme. Callers upscale with -/// interpolation disabled to keep every module a sharp square. -public struct CmxPairingQRBitmap: Sendable { - /// Quiet-zone width baked around the code, in modules. Four is the - /// ISO/IEC 18004 minimum; scanners (third-party ones especially) - /// routinely fail on codes whose surrounding white margin is thinner. - public static let quietZoneModules = 4 - - /// Creates the renderer. It is stateless: construct one inline at the - /// call site. - public init() {} - - /// Renders `payload` to a one-pixel-per-module `CGImage` with the quiet - /// zone included, or `nil` when Core Image produces no code (empty or - /// over-capacity payload). - /// - /// ECC M rather than L: the routes-only payload is small enough that M - /// still keeps the code at QR version 6 or lower (asserted by tests), and - /// the extra redundancy tolerates the glare, moire, and off-angle blur of - /// photographing a glossy Mac screen. L would maximize module size, but - /// module size is not the binding constraint at these payload sizes. - public func makeImage(payload: String) -> CGImage? { - let filter = CIFilter.qrCodeGenerator() - filter.message = Data(payload.utf8) - filter.correctionLevel = "M" - guard let output = filter.outputImage, output.extent.width > 0 else { - return nil - } - // The generator emits pure black-on-white at one pixel per module - // with a 1-module margin; composite over white to widen that margin - // to the full quiet zone. - let padding = CGFloat(Self.quietZoneModules - 1) - let paddedRect = output.extent.insetBy(dx: -padding, dy: -padding) - let white = CIImage(color: .white).cropped(to: paddedRect) - let composited = output.composited(over: white) - return CIContext().createCGImage(composited, from: paddedRect) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift deleted file mode 100644 index 64618468..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift +++ /dev/null @@ -1,310 +0,0 @@ -import Foundation - -/// The minimal pairing-QR grammar: expected Mac account/build metadata plus -/// plain `host:port` routes in the URL query. -/// -/// `cmux-ios://attach?v=2&ub=<stack-user-id>&pc=<compat>&av=<version>&ab=<build>&r=<host>:<port>[&r=<host>:<port>...]` -/// -/// A pairing QR needs to tell the phone where to dial and which non-secret -/// account/build context to check before dialing. The account value is the -/// opaque Stack user id, never the email itself. Everything else the earlier -/// grammars carried has a better channel or no reason to exist: -/// - **No auth token.** The owner's Stack access token is the host's sole -/// authorization gate; a token in the QR authorized nothing and made the -/// code look like a leaked credential. -/// - **No expiry.** Ticket age authorizes nothing, so a code that sat on -/// screen for an hour still pairs. -/// - **No display name, no device id.** Both arrive post-handshake from -/// `mobile.host.status`; the decoder leaves `macDeviceID` empty and the -/// shell adopts the host-reported identity once connected. -/// - **No loopback, ever.** Routes are Tailscale `host:port` only: the -/// encoder drops a DEBUG Mac's dev loopback route instead of encoding it, -/// the Mac refuses to mint a QR without a Tailscale route (it shows the -/// set-up-Tailscale guidance instead), and the decoder rejects loopback -/// hosts outright, so a scanned code can never point a phone at itself. -/// Loopback pairing for the simulator/dev flows uses the injected attach -/// URL path, not a QR. Dropping loopback is also the pairing-latency fix: -/// a scanned loopback route sorted first and made the phone dial itself -/// into an `NWConnection` `.waiting` black hole for the full request -/// timeout before the Tailscale route was ever tried. -/// -/// The payload is deliberately *not* wrapped in base64 JSON: anyone can read -/// the URL off the QR and see for themselves that it carries only an address. -/// Plain text is also smaller, which lowers the QR version (fewer, larger -/// modules) and makes the code scan faster from a Mac screen. -/// -/// Compatibility: this grammar only ever appears in the Mac's pairing QR. -/// Workspace-scoped tickets, dev loopback tickets, and every RPC consumer -/// keep the compact v1 JSON payload (``CmxAttachTicketCompactCoder``), and the -/// decoder keeps accepting both that and the legacy full-key grammar. -public struct CmxPairingQRCode: Sendable { - /// The grammar version carried in the URL's `v` query item. Distinct from - /// ``CmxAttachTicket/currentVersion`` (the ticket *structure* version): - /// `v=1` URLs carry a base64 JSON `payload`, `v=2` URLs carry bare routes. - public static let version = 2 - - /// Defensive cap on routes accepted from a scanned code. The Mac's route - /// resolver emits at most a couple (MagicDNS name + Tailscale IP); a QR - /// stuffed with dozens of routes is hostile input that would otherwise - /// turn into a long chain of dial attempts. - public static let maximumRouteCount = 8 - - /// Creates the codec. It is stateless: construct one inline at the call - /// site; every instance speaks the same grammar version. - public init() {} - - /// Encode `ticket` as a v2 pairing URL, or `nil` when the ticket does not - /// qualify (see ``canEncode(_:routeDisclosureMode:)``); callers fall back - /// to the compact v1 payload so every ticket still has an attach URL. - /// - /// Only the ticket's Tailscale routes are encoded: a DEBUG Mac's dev - /// loopback route is dropped, never written into a scannable code. - public func encode( - _ ticket: CmxAttachTicket, - routeDisclosureMode: CmxPairingRouteDisclosureMode - ) -> String? { - guard routeDisclosureMode == .legacyPrivateNetworkCompatibility, - let routes = encodableRoutes(of: ticket) else { - return nil - } - var items: [String] = ["v=\(Self.version)"] - if let userID = normalizedNonEmpty(ticket.macUserID) { - items.append("ub=\(percentEncodeQueryValue(userID))") - } - if let compatibilityVersion = ticket.macPairingCompatibilityVersion { - items.append("pc=\(compatibilityVersion)") - } - if let version = normalizedNonEmpty(ticket.macAppVersion) { - items.append("av=\(percentEncodeQueryValue(version))") - } - if let build = normalizedNonEmpty(ticket.macAppBuild) { - items.append("ab=\(percentEncodeQueryValue(build))") - } - let routeItems = routes.map { route -> String in - guard case let .hostPort(host, port) = route.endpoint else { - // Unreachable: `encodableRoutes` admits host/port endpoints only. - return "" - } - return "r=\(hostPortString(host: host, port: port))" - } - items.append(contentsOf: routeItems) - // The scheme is channel-specific (see ``CmxPairingURLScheme``): a dev - // Mac's QR opens the dev iOS build, a release Mac's QR opens the - // release build, and the system camera can no longer hand a beta/prod - // code to a dev build that also claimed the scheme. - return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&") - } - - /// Whether `ticket` is expressible in the minimal grammar under the - /// explicitly selected disclosure mode; see ``encodableRoutes(of:)`` for - /// the rules. - public func canEncode( - _ ticket: CmxAttachTicket, - routeDisclosureMode: CmxPairingRouteDisclosureMode - ) -> Bool { - routeDisclosureMode == .legacyPrivateNetworkCompatibility - && encodableRoutes(of: ticket) != nil - } - - /// The route subsequence a v2 pairing URL would carry for `ticket`, or - /// `nil` when the ticket is not expressible in the minimal grammar. - /// - /// Expressible means: an unscoped pairing ticket whose Tailscale routes - /// are exactly the canonical `host:port` sequence the decoder - /// resynthesizes (ids `tailscale`, `tailscale_2`, ... and priorities 10, - /// 20, ...), with no loopback host and no host that needs escaping. - /// The only routes this grammar may silently drop are loopback ones (a - /// DEBUG Mac's dev loopback route), which no phone may ever dial anyway. - /// Anything else (workspace-scoped tickets, custom route ids, no - /// Tailscale route at all, or a non-Tailscale fallback route such as an - /// iroh peer that the bare `host:port` grammar cannot express) keeps the - /// compact v1 payload so the mapping stays lossless. - private func encodableRoutes(of ticket: CmxAttachTicket) -> [CmxAttachRoute]? { - guard ticket.version == CmxAttachTicket.currentVersion, - ticket.workspaceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - ticket.terminalID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false else { - return nil - } - guard ticket.routes.allSatisfy({ $0.kind == .tailscale || CmxLoopbackHost().matches($0) }) else { - return nil - } - let routes = ticket.routes.filter { $0.kind == .tailscale } - guard !routes.isEmpty, routes.count <= Self.maximumRouteCount else { - return nil - } - for (index, route) in routes.enumerated() { - guard route.id == synthesizedRouteID(index: index), - route.priority == synthesizedRoutePriority(index: index), - case let .hostPort(host, _) = route.endpoint, - !CmxLoopbackHost().matches(host), - isPlainHost(host) else { - return nil - } - } - return routes - } - - /// Whether `components` (an already-parsed `cmux-ios://attach` URL) speaks - /// this grammar. v1 URLs carry the base64 `payload` item instead. - public func isPairingCodeURL(_ components: URLComponents) -> Bool { - components.queryItems?.first(where: { $0.name == "v" })?.value == "\(Self.version)" - } - - /// The integer grammar version declared by an attach URL's `v` query item, - /// or `nil` when absent or non-numeric. Used to tell a *newer* grammar - /// (`v` greater than ``version``) apart from a malformed code so the user is - /// told to update the app instead of seeing the generic invalid-code copy. - public static func attachURLVersion(_ components: URLComponents) -> Int? { - guard let raw = components.queryItems?.first(where: { $0.name == "v" })?.value else { - return nil - } - return Int(raw) - } - - /// Whether `rawValue` is a v2 pairing URL. String-level convenience for - /// callers that hold the encoded URL (the Mac's pairing window asserting - /// the code it is about to display speaks the minimal grammar). - public func isPairingCodeURLString(_ rawValue: String) -> Bool { - guard let url = URL(string: rawValue), - CmxPairingURLScheme.isPairingScheme(url.scheme), - url.host == "attach", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return false - } - return isPairingCodeURL(components) - } - - /// Decode a v2 pairing URL into a validated ``CmxAttachTicket``. - /// - /// The ticket comes back unscoped with an empty `macDeviceID`; the shell - /// recovers the Mac's identity post-handshake from `mobile.host.status`. - /// - Parameter components: The parsed `cmux-ios://attach?v=2&...` URL. - /// - Throws: ``MobileSyncPairingPayloadError/invalidURL`` for malformed - /// input and ``MobileSyncPairingPayloadError/loopbackRouteRejected`` - /// when any route names a loopback host (a scanned code must never - /// point the phone at itself). - public func decode(_ components: URLComponents) throws -> CmxAttachTicket { - guard isPairingCodeURL(components) else { - throw MobileSyncPairingPayloadError.invalidURL - } - let rawRoutes = (components.queryItems ?? []) - .filter { $0.name == "r" } - .compactMap(\.value) - guard !rawRoutes.isEmpty, rawRoutes.count <= Self.maximumRouteCount else { - throw MobileSyncPairingPayloadError.invalidURL - } - let routes = try rawRoutes.enumerated().map { index, rawRoute -> CmxAttachRoute in - let (host, port) = try parseHostPort(rawRoute) - guard !CmxLoopbackHost().matches(host) else { - throw MobileSyncPairingPayloadError.loopbackRouteRejected - } - return try CmxAttachRoute( - id: synthesizedRouteID(index: index), - kind: .tailscale, - endpoint: .hostPort(host: host, port: port), - priority: synthesizedRoutePriority(index: index) - ) - } - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "", - macDisplayName: nil, - macUserEmail: queryValue(named: "e", in: components), - macUserID: queryValue(named: "ub", in: components), - macPairingCompatibilityVersion: queryInt(named: "pc", in: components) ?? 0, - macAppVersion: queryValue(named: "av", in: components), - macAppBuild: queryValue(named: "ab", in: components), - routes: routes, - expiresAt: nil, - authToken: nil - ) - try ticket.validate() - return ticket - } -} -private extension CmxPairingQRCode { - /// The route id the Mac's route resolver mints for the route at `index` - /// (`tailscale` for the first, `tailscale_N` after). - func synthesizedRouteID(index: Int) -> String { - index == 0 - ? CmxAttachTransportKind.tailscale.rawValue - : "\(CmxAttachTransportKind.tailscale.rawValue)_\(index + 1)" - } - - /// The priority the Mac's route resolver assigns the route at `index`. - func synthesizedRoutePriority(index: Int) -> Int { - 10 + index * 10 - } - - /// `host:port`, bracketing IPv6 literals. - func hostPortString(host: String, port: Int) -> String { - host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)" - } - - /// Parse `host:port` (with optional IPv6 brackets) from a query value. - func parseHostPort(_ rawValue: String) throws -> (String, Int) { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - let host: Substring - let portText: Substring - if trimmed.hasPrefix("[") { - guard let closing = trimmed.firstIndex(of: "]"), - closing > trimmed.startIndex else { - throw MobileSyncPairingPayloadError.invalidURL - } - host = trimmed[trimmed.index(after: trimmed.startIndex)..<closing] - let afterBracket = trimmed.index(after: closing) - guard afterBracket < trimmed.endIndex, trimmed[afterBracket] == ":" else { - throw MobileSyncPairingPayloadError.invalidURL - } - portText = trimmed[trimmed.index(after: afterBracket)...] - } else { - guard let separator = trimmed.lastIndex(of: ":") else { - throw MobileSyncPairingPayloadError.invalidURL - } - host = trimmed[..<separator] - portText = trimmed[trimmed.index(after: separator)...] - } - guard !host.isEmpty, isPlainHost(String(host)) else { - throw MobileSyncPairingPayloadError.invalidURL - } - guard let port = Int(portText), (1...65535).contains(port) else { - throw MobileSyncPairingPayloadError.invalidPort(Int(portText) ?? 0) - } - return (String(host), port) - } - - /// Whether `host` is a bare DNS name or IP literal that needs no escaping - /// in a URL query (letters, digits, `.`, `-`, `_`, and `:` for IPv6). - func isPlainHost(_ host: String) -> Bool { - !host.isEmpty && host.utf8.allSatisfy { byte in - (48...57).contains(byte) // 0-9 - || (65...90).contains(byte) // A-Z - || (97...122).contains(byte) // a-z - || byte == UInt8(ascii: ".") - || byte == UInt8(ascii: "-") - || byte == UInt8(ascii: "_") - || byte == UInt8(ascii: ":") - } - } - - func queryValue(named name: String, in components: URLComponents) -> String? { - normalizedNonEmpty(components.queryItems?.first(where: { $0.name == name })?.value) - } - - func queryInt(named name: String, in components: URLComponents) -> Int? { - guard let value = queryValue(named: name, in: components) else { return nil } - return Int(value) - } - - func normalizedNonEmpty(_ value: String?) -> String? { - let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed?.isEmpty == false ? trimmed : nil - } - - func percentEncodeQueryValue(_ value: String) -> String { - var allowed = CharacterSet.urlQueryAllowed - allowed.remove(charactersIn: "&=+") - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingRouteDisclosureMode.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingRouteDisclosureMode.swift deleted file mode 100644 index 621fd456..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingRouteDisclosureMode.swift +++ /dev/null @@ -1,13 +0,0 @@ -/// The private-route disclosure policy for a scannable attach payload. -/// -/// Callers must choose explicitly so adding a route to a ticket cannot silently -/// add it to a QR code. The legacy mode exists only while released clients still -/// require Tailscale host routes during the Iroh migration. -public enum CmxPairingRouteDisclosureMode: Equatable, Sendable { - /// Encode only Iroh EndpointIDs. All Iroh hints and every host/port or URL - /// route are removed. - case irohIdentityOnly - /// Preserve the pre-Iroh compact route grammar for released clients. - /// This may disclose private-network routes and must not become a default. - case legacyPrivateNetworkCompatibility -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingURLScheme.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingURLScheme.swift deleted file mode 100644 index 796107eb..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingURLScheme.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -/// The channel-specific URL scheme carried by cmux pairing/attach deep links. -/// -/// All builds used to register and emit one scheme (`cmux-ios`), so scanning a -/// beta/prod pairing QR with the iOS Camera app could open a *dev* build that -/// happened to be installed (the OS picks an arbitrary app when several claim -/// a scheme). The scheme is therefore channel-specific, mirroring how -/// `MobileBuildType` splits channels: -/// -/// - **Development (DEBUG)** builds — local Xcode and `reload.sh` tagged -/// builds on both Mac and iPhone — register and emit ``development``. -/// - **Release** builds (TestFlight beta and App Store prod) register and -/// emit ``release``. Beta and prod share a scheme because they are the same -/// compile configuration and a phone realistically has only one of them. -/// -/// Emitters (the Mac building a pairing QR or attach URL) use ``current`` so a -/// dev Mac pairs a dev phone and a release Mac pairs a release phone via the -/// system camera. Parsers (the in-app scanner, manual paste, the root scene's -/// deep-link gate) accept *any* pairing scheme via ``isPairingScheme(_:)`` / -/// ``hasPairingScheme(_:)``, so cross-channel pairing still works when the -/// user scans from inside the app. -/// -/// The iOS app's registered scheme comes from `CMUX_IOS_URL_SCHEME` in -/// `ios/Config/Shared.xcconfig` (dev) and `ios/Config/Release.xcconfig` -/// (release); keep those values in sync with these constants. -/// -/// lint:allow namespace-type — the build channel's URL scheme is a pure -/// compile-time constant set with no per-instance state to inject; these -/// scheme strings and the stateless pairing-scheme predicates are a genuine -/// namespace, like the sanctioned FFI/seam holders. -public struct CmxPairingURLScheme { - private init() {} - - /// The scheme Release (TestFlight beta + App Store) builds register and emit. - public static let release = "cmux-ios" - - /// The scheme development (DEBUG/tagged) builds register and emit. - public static let development = "cmux-ios-dev" - - /// Every scheme any cmux build may emit; parsers accept all of them. - public static let all: [String] = [release, development] - - /// The scheme this build emits in pairing QRs and attach URLs. - public static var current: String { - scheme(isDevelopmentBuild: isDevelopmentBuild) - } - - /// Pure channel-to-scheme mapping, injected with the compile flag so the - /// derivation is testable from a single build configuration. - public static func scheme(isDevelopmentBuild: Bool) -> String { - isDevelopmentBuild ? development : release - } - - /// Whether `scheme` is a pairing scheme from any cmux channel. - public static func isPairingScheme(_ scheme: String?) -> Bool { - guard let scheme else { return false } - return all.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame } - } - - /// Whether `rawValue` starts with any channel's pairing scheme (the - /// scanner/paste-side prefix check, before URL parsing). - public static func hasPairingScheme(_ rawValue: String) -> Bool { - let lowercased = rawValue.lowercased() - return all.contains { lowercased.hasPrefix($0 + "://") } - } - - private static var isDevelopmentBuild: Bool { - #if DEBUG - true - #else - false - #endif - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterProviding.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterProviding.swift deleted file mode 100644 index 2e42d207..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRetryAfterProviding.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// A transport-neutral server directive that forbids another request until a -/// validated delay has elapsed. -public protocol CmxRetryAfterProviding: Error, Sendable { - var retryAfterSeconds: Int? { get } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRoutePingResult.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRoutePingResult.swift deleted file mode 100644 index 7dd8a757..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRoutePingResult.swift +++ /dev/null @@ -1,53 +0,0 @@ -/// The outcome of a single reachability probe (``CmxRoutePinging/ping(_:timeoutNanoseconds:)``) -/// against one route's address. This is a pure TCP connect: it proves whether the -/// phone can open a socket to the Mac's host/port right now, independent of the -/// live event-stream/RPC subscription. That distinction is the whole point of the -/// Computers screen's ping: a workspace can show "Disconnected" (the live stream -/// dropped) while the Mac is perfectly reachable, and this surfaces that fact. -/// -/// Lives in the core package (not the transport package) so UI/model code can -/// depend on the result and the ``CmxRoutePinging`` seam without importing the -/// concrete network transport. -public enum CmxRoutePingResult: Sendable, Equatable { - /// The TCP connection opened; the Mac is reachable. Carries the round-trip - /// connect latency in whole milliseconds. - case reachable(latencyMilliseconds: Int) - /// The address answered with a refusal: the host is up but nothing is - /// listening on the port (cmux not running, or mobile pairing off). - case refused - /// No route to the host: off Tailscale, asleep, or on another network. - case unreachable - /// The connect attempt did not complete before the timeout. - case timedOut - /// DNS resolution of the host failed. - case dnsFailed - /// The OS blocked the connection (iOS Local Network privacy). - case permissionDenied - /// Any other failure; carries a short description for display/logging. - case failed(description: String) - /// The route carries no host/port endpoint this probe can dial. - case unsupportedRoute -} - -extension CmxRoutePingResult { - /// Whether the probe proved the Mac's address is reachable at the TCP layer. - /// Both ``reachable`` and ``refused`` qualify: a refusal is an RST from a live - /// host, which proves the address is reachable even though nothing is - /// listening on the port. Use ``isListening`` for "the cmux port answered". - public var isReachable: Bool { - switch self { - case .reachable, .refused: - return true - case .unreachable, .timedOut, .dnsFailed, .permissionDenied, .failed, - .unsupportedRoute: - return false - } - } - - /// Whether a service actually accepted the connection on the cmux port (only - /// ``reachable``), as opposed to the host merely being reachable. - public var isListening: Bool { - if case .reachable = self { return true } - return false - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRoutePinging.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRoutePinging.swift deleted file mode 100644 index efda32ca..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxRoutePinging.swift +++ /dev/null @@ -1,22 +0,0 @@ -/// Probes whether the phone can reach a Mac route right now. Lives in the core -/// package so UI/model code can depend on this seam (and a fake) without -/// importing the concrete network transport; the production implementation -/// (`CmxNetworkRoutePinger`) lives in the transport package and is injected from -/// the object graph, e.g. through the shell store. -public protocol CmxRoutePinging: Sendable { - /// Probe one route, returning the connect latency or a classified failure. - /// Never throws: every outcome is folded into a ``CmxRoutePingResult``. - /// - Parameters: - /// - route: The route to probe. Non-host/port routes return - /// ``CmxRoutePingResult/unsupportedRoute``. - /// - timeoutNanoseconds: Connect deadline. - func ping(_ route: CmxAttachRoute, timeoutNanoseconds: UInt64) async -> CmxRoutePingResult -} - -extension CmxRoutePinging { - /// Probe with the default 5s deadline so a dead route resolves quickly - /// instead of hanging the Ping button. - public func ping(_ route: CmxAttachRoute) async -> CmxRoutePingResult { - await ping(route, timeoutNanoseconds: 5 * 1_000_000_000) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscalePeerAddress.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscalePeerAddress.swift deleted file mode 100644 index 93c6f446..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscalePeerAddress.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Darwin -import Foundation - -/// A canonical numeric address assigned to one Tailscale peer. -/// -/// Construction rejects generic CGNAT, public, private-LAN, and Tailscale -/// service addresses. Callers can therefore persist ``value`` as a transport -/// target without retaining a DNS dependency. -public struct CmxTailscalePeerAddress: Hashable, Sendable { - /// The address family used by this peer address. - public enum Family: Hashable, Sendable { - /// A peer address in Tailscale's `100.64.0.0/10` range. - case ipv4 - /// A peer address in Tailscale's `fd7a:115c:a1e0::/48` range. - case ipv6 - } - - /// The canonical numeric spelling suitable for a host/port endpoint. - public let value: String - /// The numeric address family. - public let family: Family - let bytes: [UInt8] - - /// Parses one numeric Tailscale peer address. - /// - Parameter rawValue: An IPv4 or IPv6 literal without brackets or a zone. - public init?(_ rawValue: String) { - let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, value == rawValue else { return nil } - - if let parsed = Self.parseIPv4(value), Self.isTailscaleIPv4Peer(parsed.bytes) { - self.value = parsed.canonical - family = .ipv4 - bytes = parsed.bytes - return - } - if let parsed = Self.parseIPv6(value), Self.isTailscaleIPv6Peer(parsed.bytes) { - self.value = parsed.canonical - family = .ipv6 - bytes = parsed.bytes - return - } - return nil - } - - private static func parseIPv4(_ value: String) -> (canonical: String, bytes: [UInt8])? { - var address = in_addr() - guard value.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { return nil } - let bytes = withUnsafeBytes(of: &address) { Array($0) } - var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) - guard inet_ntop(AF_INET, &address, &buffer, socklen_t(buffer.count)) != nil else { return nil } - return (decode(buffer), bytes) - } - - private static func parseIPv6(_ value: String) -> (canonical: String, bytes: [UInt8])? { - guard !value.contains("%") else { return nil } - var address = in6_addr() - guard value.withCString({ inet_pton(AF_INET6, $0, &address) }) == 1 else { return nil } - let bytes = withUnsafeBytes(of: &address) { Array($0) } - var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(buffer.count)) != nil else { return nil } - return (decode(buffer).lowercased(), bytes) - } - - private static func decode(_ buffer: [CChar]) -> String { - String( - decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, - as: UTF8.self - ) - } - - private static func isTailscaleIPv4Peer(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 4, - bytes[0] == 100, - (bytes[1] & 0xC0) == 64 else { - return false - } - // Tailscale reserves these ranges for local services and test traffic; - // they do not identify a peer node. - if bytes[1] == 100, bytes[2] == 0 || bytes[2] == 100 { - return false - } - if bytes[1] == 115, bytes[2] == 92 || bytes[2] == 93 { - return false - } - return true - } - - private static func isTailscaleIPv6Peer(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 16, - bytes.starts(with: [0xFD, 0x7A, 0x11, 0x5C, 0xA1, 0xE0]) else { - return false - } - // `fd7a:115c:a1e0::53` is the local MagicDNS service, not a peer. - let magicDNS = [UInt8](repeating: 0, count: 9) + [0x53] - return Array(bytes[6...]) != magicDNS - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscalePeerRecord.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscalePeerRecord.swift deleted file mode 100644 index d225b9f1..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscalePeerRecord.swift +++ /dev/null @@ -1,13 +0,0 @@ -/// One peer identity from the authenticated local Tailscale status snapshot. -public struct CmxTailscalePeerRecord: Equatable, Sendable { - /// Tailscale's stable identifier for the peer when the client supplied it. - public let stableID: String? - /// The normalized fully qualified MagicDNS name without a trailing dot. - public let dnsName: String - /// Every numeric peer address carried by the same status record. - public let addresses: [CmxTailscalePeerAddress] - /// The deterministic numeric transport target, preferring IPv4 over IPv6. - public let preferredAddress: CmxTailscalePeerAddress - /// Whether this record came from the status snapshot's `Self` entry. - public let isLocalDevice: Bool -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolutionError.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolutionError.swift deleted file mode 100644 index edfa4718..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolutionError.swift +++ /dev/null @@ -1,20 +0,0 @@ -/// Fail-closed errors produced while binding a MagicDNS input to one local -/// Tailscale control-plane peer record. -public enum CmxTailscaleStatusPeerResolutionError: Error, Equatable, Sendable { - /// The requested value was not a syntactically valid fully qualified `*.ts.net` name. - case invalidMagicDNSName - /// The status command did not return a bounded JSON object. - case malformedStatus - /// The local Tailscale backend was not running when the snapshot was read. - case statusNotRunning - /// No peer in the status snapshot had the exact normalized DNS name. - case peerNotFound - /// More than one status record claimed the exact normalized DNS name. - case ambiguousPeer - /// The exact name identified this device rather than a remote peer. - case localDeviceNotAllowed - /// The matched record had no numeric addresses. - case missingPeerAddresses - /// At least one address in the matched record was not a Tailscale peer address. - case invalidPeerAddress -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift deleted file mode 100644 index d41e4f2c..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation - -/// Resolves a `*.ts.net` input against an authenticated `tailscale status --json` -/// snapshot and returns one deterministic numeric transport target. -/// -/// This parser does not perform DNS. It trusts only the local Tailscale daemon's -/// control-plane peer map, requires one exact record, validates every address in -/// that record, and then prefers IPv4 over IPv6. The caller persists the numeric -/// result so iOS never has to trust or re-resolve the DNS name. -public struct CmxTailscaleStatusPeerResolver: Sendable { - /// Maximum accepted status size, bounding local command output parsing. - public static let maximumStatusBytes = 8 * 1024 * 1024 - /// Maximum peer records inspected from one status snapshot. - public static let maximumPeerRecords = 16_384 - - /// Creates a stateless status resolver. - public init() {} - - /// Finds one exact peer record for a MagicDNS name. - /// - Parameters: - /// - magicDNSName: A fully qualified `*.ts.net` name, with an optional trailing dot. - /// - statusJSON: Authenticated local output from `tailscale status --json`. - /// - allowLocalDevice: Whether a matching `Self` record may be returned. - /// - Returns: The exact peer record and deterministic numeric target. - /// - Throws: ``CmxTailscaleStatusPeerResolutionError`` when the name or status is unsafe. - public func resolve( - magicDNSName: String, - statusJSON: Data, - allowLocalDevice: Bool = false - ) throws -> CmxTailscalePeerRecord { - guard let requestedName = normalizedMagicDNSName(magicDNSName) else { - throw CmxTailscaleStatusPeerResolutionError.invalidMagicDNSName - } - guard !statusJSON.isEmpty, - statusJSON.count <= Self.maximumStatusBytes, - let root = try? JSONSerialization.jsonObject(with: statusJSON) as? [String: Any] else { - throw CmxTailscaleStatusPeerResolutionError.malformedStatus - } - guard root["BackendState"] as? String == "Running" else { - throw CmxTailscaleStatusPeerResolutionError.statusNotRunning - } - - var candidates: [(object: [String: Any], isLocalDevice: Bool)] = [] - if let local = root["Self"] as? [String: Any] { - candidates.append((local, true)) - } - if let peers = root["Peer"] as? [String: Any] { - guard peers.count <= Self.maximumPeerRecords else { - throw CmxTailscaleStatusPeerResolutionError.malformedStatus - } - candidates.append(contentsOf: peers.values.compactMap { value in - guard let object = value as? [String: Any] else { return nil } - return (object, false) - }) - } else if root["Peer"] != nil, !(root["Peer"] is NSNull) { - throw CmxTailscaleStatusPeerResolutionError.malformedStatus - } - - let matches = candidates.filter { candidate in - guard let dnsName = candidate.object["DNSName"] as? String else { return false } - return normalizedDNSName(dnsName) == requestedName - } - guard !matches.isEmpty else { - throw CmxTailscaleStatusPeerResolutionError.peerNotFound - } - guard matches.count == 1, let match = matches.first else { - throw CmxTailscaleStatusPeerResolutionError.ambiguousPeer - } - guard allowLocalDevice || !match.isLocalDevice else { - throw CmxTailscaleStatusPeerResolutionError.localDeviceNotAllowed - } - guard let rawAddresses = match.object["TailscaleIPs"] as? [Any], - !rawAddresses.isEmpty else { - throw CmxTailscaleStatusPeerResolutionError.missingPeerAddresses - } - - var addresses = Set<CmxTailscalePeerAddress>() - for rawAddress in rawAddresses { - guard let value = rawAddress as? String, - let address = CmxTailscalePeerAddress(value) else { - throw CmxTailscaleStatusPeerResolutionError.invalidPeerAddress - } - addresses.insert(address) - } - guard !addresses.isEmpty else { - throw CmxTailscaleStatusPeerResolutionError.missingPeerAddresses - } - let orderedAddresses = addresses.sorted(by: Self.addressPrecedes) - guard let preferredAddress = orderedAddresses.first else { - throw CmxTailscaleStatusPeerResolutionError.missingPeerAddresses - } - - return CmxTailscalePeerRecord( - stableID: (match.object["ID"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - dnsName: requestedName, - addresses: orderedAddresses, - preferredAddress: preferredAddress, - isLocalDevice: match.isLocalDevice - ) - } - - private func normalizedMagicDNSName(_ rawName: String) -> String? { - guard let name = normalizedDNSName(rawName), name.hasSuffix(".ts.net") else { - return nil - } - let labels = name.split(separator: ".", omittingEmptySubsequences: false) - guard labels.count >= 3, name.count <= 253 else { return nil } - for label in labels { - guard !label.isEmpty, - label.count <= 63, - label.first != "-", - label.last != "-", - label.utf8.allSatisfy({ byte in - (byte >= 0x61 && byte <= 0x7A) || - (byte >= 0x30 && byte <= 0x39) || - byte == 0x2D - }) else { - return nil - } - } - return name - } - - private func normalizedDNSName(_ rawName: String) -> String? { - let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let name = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed - guard !name.isEmpty, !name.hasSuffix(".") else { return nil } - return name - } - - private static func addressPrecedes( - _ lhs: CmxTailscalePeerAddress, - _ rhs: CmxTailscalePeerAddress - ) -> Bool { - if lhs.family != rhs.family { - return lhs.family == .ipv4 - } - return lhs.bytes.lexicographicallyPrecedes(rhs.bytes) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift deleted file mode 100644 index 8b34633a..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift +++ /dev/null @@ -1,489 +0,0 @@ -import Foundation - -/// The address shape used to reach an attach route. -public enum CmxAttachEndpoint: Equatable, Sendable { - /// The maximum number of reachability hints accepted for one Iroh peer. - public static let maximumIrohPathHintCount = 16 - - /// A direct host and TCP port. - case hostPort(host: String, port: Int) - /// An authenticated Iroh identity plus untrusted reachability hints. - case peer(identity: CmxIrohPeerIdentity, pathHints: [CmxIrohPathHint]) - /// A URL-based transport endpoint. - case url(String) -} - -extension CmxAttachEndpoint: Codable { - private enum CodingKeys: String, CodingKey { - case type - case host - case port - case id - case relayHint = "relay_hint" - case directAddrs = "direct_addrs" - case relayURL = "relay_url" - case pathHints = "path_hints" - case url - } - - private enum EndpointType: String, Codable { - case hostPort = "host_port" - case peer - case url - } - - /// Decodes and validates an attach endpoint. - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(EndpointType.self, forKey: .type) - switch type { - case .hostPort: - self = try .hostPort( - host: container.decode(String.self, forKey: .host), - port: container.decode(Int.self, forKey: .port) - ) - case .peer: - let identity = try CmxIrohPeerIdentity( - endpointID: try container.decode(String.self, forKey: .id) - ) - if let pathHints = try container.decodeIfPresent( - [CmxIrohPathHint].self, - forKey: .pathHints - ) { - self = .peer(identity: identity, pathHints: pathHints) - } else { - self = try .peer( - id: identity.endpointID, - relayHint: try container.decodeIfPresent(String.self, forKey: .relayHint), - directAddrs: try container.decodeIfPresent( - [String].self, - forKey: .directAddrs - ) ?? [], - relayURL: try container.decodeIfPresent(String.self, forKey: .relayURL) - ) - } - case .url: - self = try .url(container.decode(String.self, forKey: .url)) - } - } - - /// Encodes the endpoint while omitting unsafe legacy Iroh hint forms. - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case let .hostPort(host, port): - try container.encode(EndpointType.hostPort, forKey: .type) - try container.encode(host, forKey: .host) - try container.encode(port, forKey: .port) - case let .peer(identity, pathHints): - try container.encode(EndpointType.peer, forKey: .type) - try container.encode(identity.endpointID, forKey: .id) - // Encoding is deterministic: wall-clock freshness is applied by - // the caller's disclosure/persistence boundary. Structurally - // unsafe inert legacy values are never re-emitted. - let wireSafePathHints = pathHints.filter(\.isSafeForCurrentWireFormat) - if !wireSafePathHints.isEmpty { - try container.encode(wireSafePathHints, forKey: .pathHints) - } - - // Legacy fields cannot represent observation or expiry metadata. - // Downgrade only timeless safe hints, otherwise an expired/future - // path would be promoted indefinitely for an older consumer. - let legacySafePathHints = wireSafePathHints.filter { - $0.observedAt == nil && $0.expiresAt == nil - } - let relayHint = legacySafePathHints.first { - $0.kind == .relayIdentifier - }?.value - // Legacy `direct_addrs` cannot carry expiry, privacy, or network - // profile. Emitting private fallbacks there would silently promote - // them for old clients, so only public primary addresses downgrade. - let directAddrs = legacySafePathHints - .filter { $0.kind == .directAddress && $0.use == .primary } - .map(\.value) - let relayURL = legacySafePathHints.first { - $0.kind == .relayURL - }?.value - try container.encodeIfPresent(relayHint, forKey: .relayHint) - if !directAddrs.isEmpty { - try container.encode(directAddrs, forKey: .directAddrs) - } - try container.encodeIfPresent(relayURL, forKey: .relayURL) - case let .url(url): - try container.encode(EndpointType.url, forKey: .type) - try container.encode(url, forKey: .url) - } - } -} - -/// Validation failures for attach-route endpoints. -public enum CmxAttachRouteError: Error, Equatable, Sendable { - /// A host/port route has an empty host. - case emptyHost - /// An Iroh peer route has an empty peer identity. - case emptyPeerID - /// An Iroh direct-address hint is empty. - case emptyPeerAddress - /// A URL or relay hint is empty. - case emptyURL - /// A host/port route uses a port outside the valid TCP range. - case invalidPort(Int) - /// A peer route exceeded ``CmxAttachEndpoint/maximumIrohPathHintCount``. - case tooManyPeerPathHints(actual: Int, maximum: Int) - /// The endpoint shape does not match its declared transport kind. - case endpointMismatch(kind: CmxAttachTransportKind, endpoint: CmxAttachEndpoint) -} - -public struct CmxAttachRoute: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case id - case kind - case endpoint - case priority - } - - public let id: String - public let kind: CmxAttachTransportKind - public let endpoint: CmxAttachEndpoint - public let priority: Int - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - id: container.decode(String.self, forKey: .id), - kind: container.decode(CmxAttachTransportKind.self, forKey: .kind), - endpoint: container.decode(CmxAttachEndpoint.self, forKey: .endpoint), - priority: container.decodeIfPresent(Int.self, forKey: .priority) ?? 0 - ) - } - - public init( - id: String, - kind: CmxAttachTransportKind, - endpoint: CmxAttachEndpoint, - priority: Int = 0 - ) throws { - self.id = id - self.kind = kind - self.endpoint = endpoint - self.priority = priority - try validate() - } - - /// Validates that the endpoint shape and route kind agree. - public func validate() throws { - switch endpoint { - case let .hostPort(host, port): - guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CmxAttachRouteError.emptyHost - } - guard (1...65535).contains(port) else { - throw CmxAttachRouteError.invalidPort(port) - } - case let .peer(identity, pathHints): - guard !identity.endpointID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CmxAttachRouteError.emptyPeerID - } - guard pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount else { - throw CmxAttachRouteError.tooManyPeerPathHints( - actual: pathHints.count, - maximum: CmxAttachEndpoint.maximumIrohPathHintCount - ) - } - for pathHint in pathHints { - do { - try pathHint.validate() - } catch CmxIrohPathHintError.emptyValue { - switch pathHint.kind { - case .directAddress: - throw CmxAttachRouteError.emptyPeerAddress - case .relayIdentifier, .relayURL: - throw CmxAttachRouteError.emptyURL - } - } - } - case let .url(url): - guard !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CmxAttachRouteError.emptyURL - } - } - - switch (kind, endpoint) { - case (.tailscale, .hostPort), (.debugLoopback, .hostPort), (.iroh, .peer), (.websocket, .url): - break - default: - throw CmxAttachRouteError.endpointMismatch(kind: kind, endpoint: endpoint) - } - } - -} - -public enum CmxAttachTicketError: Error, Equatable, Sendable { - case unsupportedVersion(Int) - case noRoutes - case emptyAuthToken -} - -public struct CmxAttachTicket: Codable, Equatable, Sendable { - public static let currentVersion = 1 - - /// The canonical on-the-wire keys. Most fields use camelCase; the auth - /// token field is the one historical exception (`auth_token`). - /// - /// Encoding stays byte-compatible with what the mac side of PR 5079 already - /// produces and decodes (this exact type is shared by both the iOS and mac - /// app via `CMUXMobileCore`), so the mixed convention is preserved on the - /// encode path. Decoding is tolerant: it accepts both the canonical - /// `auth_token` key and a normalized camelCase `authToken` so a future - /// producer can migrate the token field without breaking older clients. - /// See ``decodeAuthToken(from:)``. - private enum CodingKeys: String, CodingKey { - case version - case workspaceID - case terminalID - case macDeviceID - case macDisplayName - case macUserEmail - case macUserID - case macPairingCompatibilityVersion - case macAppVersion - case macAppBuild - case routes - case expiresAt - case authToken = "auth_token" - } - - /// Tolerant decode keys for the auth-token field only. - /// - /// Holds both the canonical `auth_token` key and the normalized `authToken` - /// camelCase key so a payload speaking either convention decodes. The - /// canonical key wins when both are present. - private enum AuthTokenCodingKeys: String, CodingKey { - case canonical = "auth_token" - case camelCase = "authToken" - } - - public let version: Int - public let workspaceID: String - public let terminalID: String? - public let macDeviceID: String - public let macDisplayName: String? - /// The signed-in Mac account email the phone must match before pairing. - public let macUserEmail: String? - /// The opaque Stack user id for the Mac account. Public pairing QR codes - /// carry this instead of an email so the phone can reject the wrong - /// signed-in account without exposing an enumerable email address. - public let macUserID: String? - /// Shared mobile pairing compatibility level reported by the Mac. - public let macPairingCompatibilityVersion: Int? - /// The Mac app's marketing version, displayed with compatibility warnings. - public let macAppVersion: String? - /// The Mac app's build number, displayed with version mismatch warnings when present. - public let macAppBuild: String? - public let routes: [CmxAttachRoute] - /// When the ticket's attach token stops being usable, or `nil` for tickets - /// that never expire (the pairing QR carries no token and no expiry; Stack - /// auth is the host's sole authorization gate). Expiry is data for the - /// token consumers (`MobileCoreRPCClient`, the host's ticket store), not a - /// structural validity condition; see ``isExpired(at:)``. - public let expiresAt: Date? - public let authToken: String? - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - version: container.decode(Int.self, forKey: .version), - workspaceID: container.decode(String.self, forKey: .workspaceID), - terminalID: container.decodeIfPresent(String.self, forKey: .terminalID), - macDeviceID: container.decode(String.self, forKey: .macDeviceID), - macDisplayName: container.decodeIfPresent(String.self, forKey: .macDisplayName), - macUserEmail: container.decodeIfPresent(String.self, forKey: .macUserEmail), - macUserID: container.decodeIfPresent(String.self, forKey: .macUserID), - macPairingCompatibilityVersion: container.decodeIfPresent( - Int.self, - forKey: .macPairingCompatibilityVersion - ), - macAppVersion: container.decodeIfPresent(String.self, forKey: .macAppVersion), - macAppBuild: container.decodeIfPresent(String.self, forKey: .macAppBuild), - routes: container.decode([CmxAttachRoute].self, forKey: .routes), - expiresAt: container.decodeIfPresent(Date.self, forKey: .expiresAt), - authToken: try Self.decodeAuthToken(from: decoder) - ) - try validate() - } - - /// Decode the auth token tolerantly, accepting either the canonical - /// `auth_token` key or the normalized `authToken` key. - /// - /// - Parameter decoder: The decoder for the ticket payload. - /// - Returns: The auth token if present under either key (`auth_token` - /// takes precedence), otherwise `nil`. - private static func decodeAuthToken(from decoder: Decoder) throws -> String? { - let container = try decoder.container(keyedBy: AuthTokenCodingKeys.self) - if let canonical = try container.decodeIfPresent(String.self, forKey: .canonical) { - return canonical - } - return try container.decodeIfPresent(String.self, forKey: .camelCase) - } - - public init( - version: Int = Self.currentVersion, - workspaceID: String, - terminalID: String?, - macDeviceID: String, - macDisplayName: String?, - macUserEmail: String? = nil, - macUserID: String? = nil, - macPairingCompatibilityVersion: Int? = nil, - macAppVersion: String? = nil, - macAppBuild: String? = nil, - routes: [CmxAttachRoute], - expiresAt: Date? = nil, - authToken: String? = nil - ) throws { - self.version = version - self.workspaceID = workspaceID - self.terminalID = terminalID - self.macDeviceID = cmxCanonicalDeviceID(macDeviceID) - self.macDisplayName = macDisplayName - self.macUserEmail = macUserEmail - self.macUserID = macUserID - self.macPairingCompatibilityVersion = macPairingCompatibilityVersion - self.macAppVersion = macAppVersion - self.macAppBuild = macAppBuild - self.routes = routes - self.expiresAt = expiresAt - self.authToken = authToken - try validate() - } - - /// Structural validity only. Expiry is intentionally NOT validated here: - /// a scanned pairing QR must keep working however long it sat on screen - /// (the host authorizes by Stack account, not by ticket age). Token-based - /// consumers check ``isExpired(at:)`` where the token is actually used. - public func validate() throws { - guard version == Self.currentVersion else { - throw CmxAttachTicketError.unsupportedVersion(version) - } - if let authToken { - guard !authToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw CmxAttachTicketError.emptyAuthToken - } - } - guard !routes.isEmpty else { - throw CmxAttachTicketError.noRoutes - } - for route in routes { - try route.validate() - } - } - - /// Whether the ticket's attach token lifetime has elapsed at `now`. - /// Tickets without an expiry (`expiresAt == nil`, e.g. decoded from the - /// pairing QR) never expire. - public func isExpired(at now: Date) -> Bool { - guard let expiresAt else { - return false - } - return expiresAt <= now - } - - public func preferredRoute(supportedKinds: [CmxAttachTransportKind]) -> CmxAttachRoute? { - guard !supportedKinds.isEmpty else { - return nil - } - let orderedRoutes = routes.sorted { left, right in - if left.priority == right.priority { - return left.id < right.id - } - return left.priority < right.priority - } - let supportedKinds = Set(supportedKinds) - return orderedRoutes.first { supportedKinds.contains($0.kind) } - } -} - -public protocol CmxByteTransport: Sendable { - func connect() async throws - func receive() async throws -> Data? - func send(_ data: Data) async throws - func close() async -} - -/// Independently framed server-event bytes delivered outside the RPC control stream. -public typealias CmxIndependentEventByteStream = AsyncThrowingStream<Data, any Error> - -/// Opens the one bounded event byte stream associated with an exact transport intent. -public typealias CmxIndependentEventByteStreamProvider = @Sendable ( - CmxByteTransportRequest -) async throws -> CmxIndependentEventByteStream - -public protocol CmxByteTransportFactory: Sendable { - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport - func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport -} - -extension CmxByteTransportFactory { - /// Compatibility path for transports whose peer intent is fully represented by the route. - public func makeTransport( - for request: CmxByteTransportRequest - ) throws -> any CmxByteTransport { - try makeTransport(for: request.route) - } -} - -public protocol CmxRouteAwareByteTransportFactory: CmxByteTransportFactory { - var supportedKinds: [CmxAttachTransportKind] { get } -} - -public struct CmxRouteTransportFactoryRegistration: Sendable { - public var kind: CmxAttachTransportKind - public var factory: any CmxByteTransportFactory - - public init(kind: CmxAttachTransportKind, factory: any CmxByteTransportFactory) { - self.kind = kind - self.factory = factory - } -} - -public enum CmxRouteTransportFactoryError: Error, Equatable, Sendable { - case duplicateRouteKind(CmxAttachTransportKind) - case unsupportedRouteKind(CmxAttachTransportKind) -} - -public struct CmxRouteTransportFactory: CmxRouteAwareByteTransportFactory { - public let supportedKinds: [CmxAttachTransportKind] - private let factories: [CmxAttachTransportKind: any CmxByteTransportFactory] - - public init(_ registrations: [CmxRouteTransportFactoryRegistration]) throws { - var factories: [CmxAttachTransportKind: any CmxByteTransportFactory] = [:] - var supportedKinds: [CmxAttachTransportKind] = [] - - for registration in registrations { - guard factories[registration.kind] == nil else { - throw CmxRouteTransportFactoryError.duplicateRouteKind(registration.kind) - } - factories[registration.kind] = registration.factory - supportedKinds.append(registration.kind) - } - - self.factories = factories - self.supportedKinds = supportedKinds - } - - public func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - guard let factory = factories[route.kind] else { - throw CmxRouteTransportFactoryError.unsupportedRouteKind(route.kind) - } - return try factory.makeTransport(for: route) - } - - public func makeTransport( - for request: CmxByteTransportRequest - ) throws -> any CmxByteTransport { - guard let factory = factories[request.route.kind] else { - throw CmxRouteTransportFactoryError.unsupportedRouteKind(request.route.kind) - } - return try factory.makeTransport(for: request) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransportSessionPurpose.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransportSessionPurpose.swift deleted file mode 100644 index 1f7ef03c..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransportSessionPurpose.swift +++ /dev/null @@ -1,15 +0,0 @@ -/// The local lifecycle role of a byte-transport request. -/// -/// This value never crosses the network. Transport pools use it to keep -/// foreground diagnostics anchored to the user-visible RPC connection when -/// background aggregation or feature lanes share the same endpoint. -public enum CmxTransportSessionPurpose: UInt8, Equatable, Sendable { - /// The control session powering the currently visible Mac workspace. - case foregroundControl = 1 - /// A control session keeping a non-selected Mac's workspace list current. - case backgroundControl = 2 - /// A short-lived request that discovers or validates a route. - case probe = 3 - /// An independent feature lane sharing an admitted Iroh session. - case featureLane = 4 -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachEndpoint.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachEndpoint.swift deleted file mode 100644 index 5b434efd..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachEndpoint.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation - -/// Compact short-key DTO for ``CmxAttachEndpoint``; see -/// ``CmxAttachTicketCompactCoder`` for the grammar and key map. -/// -/// The endpoint type is implied by which keys are present (`u` url, `i` peer, -/// `h`+`p` host/port), so new payloads omit `t`. Payloads from the first -/// compact revision still spell `t` out; when present it is authoritative, so -/// those payloads keep decoding unchanged. -struct CompactAttachEndpoint: Codable { - let t: String? - let h: String? - let p: Int? - let i: String? - let rh: String? - let da: [String]? - let ru: String? - let ph: [CmxIrohPathHint]? - let u: String? - - init(_ endpoint: CmxAttachEndpoint) { - t = nil - switch endpoint { - case let .hostPort(host, port): - h = host - p = port - i = nil - rh = nil - da = nil - ru = nil - ph = nil - u = nil - case let .peer(identity, _): - h = nil - p = nil - i = identity.endpointID - // A scannable payload discloses Iroh identity only. Managed relays - // are app configuration, online discovery is authenticated, and - // first-time offline pairing resolves this EndpointID locally. - rh = nil - da = nil - ru = nil - ph = nil - u = nil - case let .url(url): - h = nil - p = nil - i = nil - rh = nil - da = nil - ru = nil - ph = nil - u = url - } - } - - func endpoint() throws -> CmxAttachEndpoint { - switch try resolvedType() { - case "host_port": - guard let h, let p else { - throw Self.corruptedEndpoint("host_port endpoint requires h and p") - } - return .hostPort(host: h, port: p) - case "peer": - guard let i else { - throw Self.corruptedEndpoint("peer endpoint requires i") - } - if let ph { - return .peer( - identity: try CmxIrohPeerIdentity(endpointID: i), - pathHints: ph - ) - } - return try .peer(id: i, relayHint: rh, directAddrs: da ?? [], relayURL: ru) - case "url": - guard let u else { - throw Self.corruptedEndpoint("url endpoint requires u") - } - return .url(u) - case let type: - throw Self.corruptedEndpoint("Unknown attach endpoint type: \(type)") - } - } - - /// The explicit `t` when the payload carries one, otherwise the type - /// implied by which keys are present. - private func resolvedType() throws -> String { - if let t { - return t - } - if u != nil { - return "url" - } - if i != nil { - return "peer" - } - if h != nil, p != nil { - return "host_port" - } - throw Self.corruptedEndpoint("Attach endpoint carries no recognizable fields") - } - - private static func corruptedEndpoint(_ message: String) -> DecodingError { - DecodingError.dataCorrupted(DecodingError.Context( - codingPath: [], - debugDescription: message - )) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachRoute.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachRoute.swift deleted file mode 100644 index 38a4fe28..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachRoute.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation - -/// Compact short-key DTO for ``CmxAttachRoute``; see -/// ``CmxAttachTicketCompactCoder`` for the grammar and key map. -/// -/// The route id is omitted on encode whenever it equals the id the decoder -/// resynthesizes from the route's kind and position (``CompactAttachTicket`` -/// owns that mapping), and is honored verbatim when present, so both the new -/// id-free payloads and the first compact revision's explicit-id payloads -/// decode to the same routes. -struct CompactAttachRoute: Codable { - let i: String? - let k: String - let p: Int? - let e: CompactAttachEndpoint - - init(_ route: CmxAttachRoute, omittingID: Bool) { - i = omittingID ? nil : route.id - k = route.kind.rawValue - p = route.priority == 0 ? nil : route.priority - e = CompactAttachEndpoint(route.endpoint) - } - - func kind() throws -> CmxAttachTransportKind { - guard let kind = CmxAttachTransportKind(rawValue: k) else { - throw DecodingError.dataCorrupted(DecodingError.Context( - codingPath: [], - debugDescription: "Unknown attach route kind: \(k)" - )) - } - return kind - } - - func route(synthesizedID: String) throws -> CmxAttachRoute { - try CmxAttachRoute( - id: i ?? synthesizedID, - kind: kind(), - endpoint: e.endpoint(), - priority: p ?? 0 - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift deleted file mode 100644 index 56434c5b..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/CompactAttachTicket.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation - -/// Compact short-key DTO for ``CmxAttachTicket``; see -/// ``CmxAttachTicketCompactCoder`` for the grammar and key map. -/// -/// `JSONDecoder` ignores unknown keys, so payloads from the first compact -/// grammar revision that still carry `e` (expiry) and `n` (display name) -/// decode here with both intentionally dropped: a pairing QR never expires, -/// and the Mac's name is read post-handshake from `mobile.host.status`. -/// New Iroh pairing payloads disclose only EndpointID identity. The explicit -/// compatibility mode temporarily retains released clients' legacy routes. -struct CompactAttachTicket: Codable { - let v: Int - let w: String? - let t: String? - let d: String - let u: String? - let pc: Int? - let av: String? - let ab: String? - let r: [CompactAttachRoute] - - init( - _ ticket: CmxAttachTicket, - routeDisclosureMode: CmxPairingRouteDisclosureMode - ) throws { - let disclosedRoutes = ticket.routes.disclosed(for: routeDisclosureMode) - guard !disclosedRoutes.isEmpty else { - throw CmxAttachTicketCompactCoderError.noRoutesForDisclosureMode( - routeDisclosureMode - ) - } - v = ticket.version - w = ticket.workspaceID.isEmpty ? nil : ticket.workspaceID - t = ticket.terminalID.flatMap { $0.isEmpty ? nil : $0 } - d = ticket.macDeviceID - u = ticket.macUserID.flatMap { $0.isEmpty ? nil : $0 } - pc = ticket.macPairingCompatibilityVersion - av = ticket.macAppVersion.flatMap { $0.isEmpty ? nil : $0 } - ab = ticket.macAppBuild.flatMap { $0.isEmpty ? nil : $0 } - r = disclosedRoutes.compacted() - } - - func ticket() throws -> CmxAttachTicket { - try CmxAttachTicket( - version: v, - workspaceID: w ?? "", - terminalID: t, - macDeviceID: d, - macDisplayName: nil, - macUserEmail: u?.contains("@") == true ? u : nil, - macUserID: u?.contains("@") == false ? u : nil, - macPairingCompatibilityVersion: pc ?? 0, - macAppVersion: av, - macAppBuild: ab, - routes: try r.expanded(), - expiresAt: nil - ) - } -} - -private extension Array where Element == CmxAttachRoute { - func disclosed(for mode: CmxPairingRouteDisclosureMode) -> Self { - switch mode { - case .irohIdentityOnly: - return compactMap { route in - guard route.kind == .iroh, - case let .peer(identity, _) = route.endpoint else { - return nil - } - return try? CmxAttachRoute( - id: route.id, - kind: route.kind, - endpoint: .peer(identity: identity, pathHints: []), - priority: route.priority - ) - } - case .legacyPrivateNetworkCompatibility: - return self - } - } - - /// Encode routes, omitting each route id the decoder can resynthesize. - func compacted() -> [CompactAttachRoute] { - var kindCounts: [CmxAttachTransportKind: Int] = [:] - return map { route in - let occurrence = (kindCounts[route.kind] ?? 0) + 1 - kindCounts[route.kind] = occurrence - let synthesized = occurrence == 1 - ? route.kind.rawValue - : "\(route.kind.rawValue)_\(occurrence)" - return CompactAttachRoute(route, omittingID: route.id == synthesized) - } - } -} - -private extension Array where Element == CompactAttachRoute { - /// Decode routes, resynthesizing each omitted route id. - func expanded() throws -> [CmxAttachRoute] { - var kindCounts: [CmxAttachTransportKind: Int] = [:] - return try map { compactRoute in - let kind = try compactRoute.kind() - let occurrence = (kindCounts[kind] ?? 0) + 1 - kindCounts[kind] = occurrence - let synthesized = occurrence == 1 - ? kind.rawValue - : "\(kind.rawValue)_\(occurrence)" - return try compactRoute.route(synthesizedID: synthesized) - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ComposerDockIntent.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ComposerDockIntent.swift deleted file mode 100644 index 3a8ba86f..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ComposerDockIntent.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// The single coherent step the surface should take in response to a compose-button -/// tap, computed by ``ComposerDockState/intentForComposeButtonTap()``. -public enum ComposerDockIntent: Sendable, Equatable { - /// No composer is presented; present it and focus the field (the plain open). - case openComposer - /// A composer is presented but suppressed or unfocused; reveal the chrome (if - /// hidden), keep it presented, and focus the field. The draft is preserved. - case revealAndFocusComposer - /// A genuinely visible, focused composer; dismiss it (the only path that closes - /// the composer from the button). - case closeComposer -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ComposerDockReducer.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ComposerDockReducer.swift deleted file mode 100644 index 91b4bd0c..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ComposerDockReducer.swift +++ /dev/null @@ -1,85 +0,0 @@ -/// The four booleans that describe the iOS terminal's bottom dock at the instant -/// the user acts on the composer, plus the pure decision that maps an action onto -/// the one coherent next step. -/// -/// The bottom dock is a tangle of four independent flags — chrome hidden vs shown, -/// composer logically presented vs not, the composer field holding first responder -/// vs not, and the software keyboard up vs down. A blind `isComposerPresented.toggle()` -/// on the compose button ignored all of them, so the cycle compose → hide → reveal → -/// compose closed a still-presented (but visually suppressed, or revealed-yet-unfocused) -/// composer and the user saw their draft vanish. This type is the single source of -/// truth for that decision so it can be unit-tested off-device and the UIKit surface -/// only has to translate the resulting ``ComposerDockIntent`` into calls. -public struct ComposerDockState: Sendable, Equatable { - /// Whether the HIDE button has visually suppressed the whole bottom chrome - /// (toolbar + composer band). The composer can be *presented* yet hidden: - /// HIDE leaves ``composerPresented`` untouched and only drops the chrome, so - /// the draft survives. - public var chromeHidden: Bool - /// Whether the composer is logically presented (the store's `isComposerPresented`, - /// mirrored onto the surface). True from the moment the composer opens until it - /// is genuinely dismissed; a HIDE does not flip it. - public var composerPresented: Bool - /// Whether the composer's text field currently holds first responder. After a - /// reveal-from-hide the chrome is back and the composer is presented, but the - /// terminal proxy (not the field) took first responder, so this is false — the - /// exact state that made the next compose tap destructive. - public var fieldFocused: Bool - /// Whether the software keyboard is currently up. - /// - /// Part of the dock's complete description, recorded so a captured trace and the - /// tests model the real state. It does NOT gate - /// ``intentForComposeButtonTap()`` today (the open/reveal/close decision turns - /// only on presented + suppressed/unfocused); it is retained for the dock's - /// faithful shape and any future keyboard-aware step. - public var keyboardUp: Bool - - /// Creates a dock state from its four flags. - /// - Parameters: - /// - chromeHidden: Whether the HIDE button has suppressed the chrome. - /// - composerPresented: Whether the composer is logically presented. - /// - fieldFocused: Whether the composer field holds first responder. - /// - keyboardUp: Whether the software keyboard is up. - public init( - chromeHidden: Bool, - composerPresented: Bool, - fieldFocused: Bool, - keyboardUp: Bool - ) { - self.chromeHidden = chromeHidden - self.composerPresented = composerPresented - self.fieldFocused = fieldFocused - self.keyboardUp = keyboardUp - } - - /// Resolve what tapping the compose accessory button should do, given this dock - /// state. - /// - /// The compose button has three jobs folded into one control, told apart by the - /// dock state: - /// - /// - **Open** when no composer is presented: present it and focus the field. - /// - **Reveal** when a composer is presented but suppressed (``chromeHidden``) - /// or visible-yet-unfocused (presented, chrome shown, field not first - /// responder — the reveal-after-hide state): bring the chrome back, keep the - /// composer presented, and focus the field. The draft is never dismissed. - /// - **Close** only when the composer is genuinely visible and focused: a real - /// "I'm done composing" tap. - /// - /// - Returns: the ``ComposerDockIntent`` the surface should carry out. - public func intentForComposeButtonTap() -> ComposerDockIntent { - guard composerPresented else { - // Nothing presented: a plain open. - return .openComposer - } - if chromeHidden || !fieldFocused { - // Presented but suppressed, or presented-and-visible yet the field lost - // first responder on a reveal. Either way the user wants the composer - // back and focused, NOT dismissed — reveal the chrome if needed and - // re-focus the field, leaving the draft intact. - return .revealAndFocusComposer - } - // Genuinely visible and focused: a real close. - return .closeComposer - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ConnectionOutageThrottle.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ConnectionOutageThrottle.swift deleted file mode 100644 index cb6ff092..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/ConnectionOutageThrottle.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -/// Pure decision logic that collapses connection-state edges into at most one -/// `ios_connection_lost` per outage and one `ios_connection_recovered` per -/// recovery. -/// -/// A flapping link sets `connectionState` to `.disconnected` and back many times; -/// instrumenting every assignment would spam. This type tracks only whether an -/// outage is currently open and emits an event solely on the *edge* between -/// connected and disconnected. It holds no reference state and is `Sendable`; the -/// caller threads the small `Bool` of outage state through ``record(transition:)``. -/// -/// ```swift -/// var throttle = ConnectionOutageThrottle() -/// if let signal = throttle.record(transition: .init(wasConnected: true, isConnected: false)) { -/// // signal == .lost — emit exactly once for this outage. -/// } -/// ``` -public struct ConnectionOutageThrottle: Sendable, Equatable { - /// Whether an outage is currently open (a lost event has fired with no - /// matching recovered event yet). - public private(set) var outageOpen: Bool - - /// Creates a throttle, optionally pre-seeded with an open outage. - /// - Parameter outageOpen: The initial outage state. Defaults to `false`. - public init(outageOpen: Bool = false) { - self.outageOpen = outageOpen - } - - /// A connected/disconnected transition observed on the shell store. - public struct Transition: Sendable, Equatable { - /// Whether the connection was connected before the transition. - public let wasConnected: Bool - /// Whether the connection is connected after the transition. - public let isConnected: Bool - - /// Creates a transition. - public init(wasConnected: Bool, isConnected: Bool) { - self.wasConnected = wasConnected - self.isConnected = isConnected - } - } - - /// The throttled outcome of a transition. - public enum Signal: Sendable, Equatable { - /// The connection just went down and no outage was already open. - case lost - /// The connection just came back and an outage was open. - case recovered - } - - /// Records a transition and returns the event to emit, if any. - /// - /// Mutates ``outageOpen`` so a subsequent flap on the same edge is suppressed. - /// Returns `.lost` only on the first connected→disconnected edge of an outage, - /// and `.recovered` only on the disconnected→connected edge that closes an - /// open outage. - /// - /// - Parameter transition: The observed connection-state transition. - /// - Returns: The ``Signal`` to emit, or `nil` if the transition is a no-op - /// for analytics (a repeated state, or a recovery with no open outage). - public mutating func record(transition: Transition) -> Signal? { - let wentDown = transition.wasConnected && !transition.isConnected - let cameUp = !transition.wasConnected && transition.isConnected - - if wentDown { - guard !outageOpen else { return nil } - outageOpen = true - return .lost - } - if cameUp { - guard outageOpen else { return nil } - outageOpen = false - return .recovered - } - return nil - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEvent.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEvent.swift deleted file mode 100644 index 6e42dd57..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEvent.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation - -/// One structured diagnostic event recorded on a hot path. -/// -/// The value is deliberately tiny and free of any allocated string: a -/// ``DiagnosticEventCode``, a monotonic ``tNanos`` timestamp, and a handful of -/// optional integer fields. Recording one is a struct copy onto an -/// `AsyncStream` continuation with no formatting work, so it is cheap enough for -/// the input and render seams that the string-based ``MobileDebugLog`` is too -/// heavy for. Formatting into text happens only later, in -/// ``DiagnosticLog/export()``. -/// -/// ```swift -/// log.record(DiagnosticEvent(.inputSeqBehind, surface: 7, a: localSeq, b: remoteSeq)) -/// ``` -public struct DiagnosticEvent: Sendable, Codable, Equatable { - /// What kind of event this is. - public var code: DiagnosticEventCode - - /// A monotonic timestamp, in nanoseconds, from a continuous clock. - /// - /// Sourced from `DispatchTime.now().uptimeNanoseconds` by the convenience - /// initializer so two events are strictly orderable without depending on - /// wall-clock skew. ``DiagnosticLog/export()`` writes one wall-clock anchor - /// in its header so a reader can convert these back to absolute time. - public var tNanos: UInt64 - - /// An optional surface identifier the event relates to. - public var surface: UInt32? - - /// An optional millisecond magnitude (e.g. silence duration, lag). - public var ms: UInt32? - - /// First optional integer payload slot; meaning is per ``code``. - public var a: Int? - - /// Second optional integer payload slot; meaning is per ``code``. - public var b: Int? - - /// Third optional integer payload slot; meaning is per ``code``. - public var c: Int? - - /// Creates an event with an explicit timestamp. - /// - /// - Parameters: - /// - code: The event kind. - /// - tNanos: A monotonic nanosecond timestamp. - /// - surface: An optional surface identifier. - /// - ms: An optional millisecond magnitude. - /// - a: First optional integer payload slot. - /// - b: Second optional integer payload slot. - /// - c: Third optional integer payload slot. - public init( - code: DiagnosticEventCode, - tNanos: UInt64, - surface: UInt32? = nil, - ms: UInt32? = nil, - a: Int? = nil, - b: Int? = nil, - c: Int? = nil - ) { - self.code = code - self.tNanos = tNanos - self.surface = surface - self.ms = ms - self.a = a - self.b = b - self.c = c - } - - /// Creates an event stamped with the current monotonic time. - /// - /// Uses `DispatchTime.now().uptimeNanoseconds`, which is monotonic within a - /// process run and cheap to read. This is the initializer hot-path call - /// sites use; it does no allocation and no string work. - /// - /// - Parameters: - /// - code: The event kind. - /// - surface: An optional surface identifier. - /// - ms: An optional millisecond magnitude. - /// - a: First optional integer payload slot. - /// - b: Second optional integer payload slot. - /// - c: Third optional integer payload slot. - public init( - _ code: DiagnosticEventCode, - surface: UInt32? = nil, - ms: UInt32? = nil, - a: Int? = nil, - b: Int? = nil, - c: Int? = nil - ) { - self.init( - code: code, - tNanos: DispatchTime.now().uptimeNanoseconds, - surface: surface, - ms: ms, - a: a, - b: b, - c: c - ) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift deleted file mode 100644 index 7bf269c4..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift +++ /dev/null @@ -1,176 +0,0 @@ -import Foundation - -/// A compact, stable identifier for one kind of diagnostic event. -/// -/// The raw value is a small ``UInt16`` so a ``DiagnosticEvent`` stays tiny and -/// an exported log row is a few bytes instead of an interpolated string. New -/// cases append a fresh raw value and never renumber an existing one, so a blob -/// exported by an older build still decodes against a newer reader. -/// -/// The cases cover the round-trip seams a dogfooder cares about: connection and -/// pairing outcome, render-grid liveness (silent re-subscribe / stream ended), -/// the input-sequence and byte-gap stalls that surface as "my keystrokes lag", -/// and a generic ``error`` bucket. -public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable { - /// A connection attempt to a paired Mac started. - case connect = 1 - /// Pairing / attach completed successfully. - case pairOk = 2 - /// Pairing / attach failed. - case pairFail = 3 - /// The render-grid stream lagged behind (a bounded render-lag counter tick). - /// - /// Reserved for the render hot path in `GhosttySurfaceView` (the existing - /// `oq.render.LAG` site). It is part of the export vocabulary now, but not - /// emitted from the shell: instrumenting the per-frame render seam is a - /// deeper injection deferred past P1, and the spec caps render-path - /// instrumentation at a single bounded counter. - case renderGridLag = 4 - /// The liveness watchdog forced a re-subscribe after a silent stream. - case livenessResubscribe = 5 - /// The render-grid push stream ended and fell back to polling. - case streamEnded = 6 - /// The local input sequence fell behind the remote-applied sequence. - case inputSeqBehind = 7 - /// A gap was detected in the delivered terminal byte stream. - case byteGap = 8 - /// A generic error at an instrumented seam. - case error = 9 - /// A pairing attempt was short-circuited because the device had no network - /// path (the reachability preflight failed before any connect). - case pairUnreachable = 10 - - // MARK: iOS composer instrumentation (draft-disappears-on-keyboard-dismiss hunt) - // - // These five codes discriminate WHY the iMessage-style composer's draft - // vanishes after the keyboard opens then closes. The draft text itself lives - // in the store (`terminalInputText`), so the symptom must be one of: the - // `isComposerPresented` flag toggled off, the composer view torn down + rebuilt - // while the flag stayed true, the draft cleared at the store, or (the residual) - // a `TextField`/`@FocusState` render blank. Logging the flag, the draft length, - // and the composer view's appear/disappear *independently* of the flag lets a - // single captured trace name which one happened. Raw values 11-17 are reserved - // for the in-flight keyboard-input instrumentation branch. - - /// The store's `isComposerPresented` flag changed (store `didSet`). `a` = 1 if - /// the composer is now presented, else 0. An unexpected `a == 0` during a bare - /// keyboard dismiss is the "flag toggled off" cause. - case composerPresentedChanged = 18 - /// The store's `terminalInputText` draft changed (store `didSet`). `a` = new - /// UTF-8 byte length; `b` = 1 if it just went to empty (a clear), else 0. A - /// clear (`b == 1`) with no submit/sign-out nearby is the "draft cleared at the - /// store" cause. - case composerInputTextChanged = 19 - /// ``TerminalComposerView`` appeared (`.onAppear`). Logged independently of - /// ``composerPresentedChanged`` so a disappear/appear pair with no flag change - /// reveals a view-recreation bug (the flag stayed true but SwiftUI rebuilt the - /// view). - case composerViewAppear = 20 - /// ``TerminalComposerView`` disappeared (`.onDisappear`). A disappear without a - /// matching ``composerPresentedChanged`` `a == 0` is a view-recreation bug, not - /// an intentional dismiss. - case composerViewDisappear = 21 - /// The composer's text field focus changed (`@FocusState`). `a` = 1 focused, - /// else 0. A focus-lost (`a == 0`) while the flag stayed presented and the view - /// stayed mounted, yet the field reads empty, isolates the residual - /// `TextField`/`@FocusState` render-blank case. - case composerFieldFocusChanged = 22 - - // COMPOSER keyboard-toggle edge case (composer shown while the - // textbox/keyboard is hidden). These pin which transition desyncs the - // composer-presented flag from the keyboard/first-responder state, and they - // land in the same `store.diagnosticLog` sink the composer events above use. - - /// `GhosttySurfaceView.setComposerActive` ran. `a` = 1 if the composer just - /// became active, else 0. `b` = the resolved first-responder owner - /// (``InputResponderIdentity`` raw value: which view holds first responder at - /// the transition). `c` = 1 if the terminal input proxy is first responder, - /// else 0. `ms` = the surface's `keyboardHeight` (points) at the transition. A - /// trace where `a == 1` but `ms == 0` and no terminal/composer responder owns - /// FR is the composer-up/keyboard-down desync. - case composerActiveTransition = 23 - - /// The docked bar's keyboard toggle button was tapped while the composer is - /// presented. `a` = 1 if the terminal input proxy was first responder when - /// tapped (so the tap would hide the keyboard), else 0. Purely diagnostic: - /// the keyboard toggle no longer dismisses the composer (the composer - /// survives a keyboard-down), so this records the tap for trace completeness. - case composerKeyboardToggleWhilePresented = 24 - - // MARK: App transport lifecycle - - /// A transport dial started. `a` is ``DiagnosticTransportKind`` and `c` is - /// the positive, process-local attempt ID shared by the matching dial - /// outcome event. - case transportDialStarted = 25 - /// A transport dial connected. Payload follows ``transportDialStarted``. - case transportDialConnected = 26 - /// A transport dial failed. `a` is ``DiagnosticTransportKind``, `b` is - /// ``DiagnosticFailureKind``, and `c` is the matching local attempt ID. - case transportDialFailed = 27 - /// The remote host identity passed authenticated endpoint validation. - case hostAuthenticated = 28 - /// The authenticated RPC session completed its readiness handshake. - case rpcReady = 29 - /// Connection recovery started after a previously usable session degraded. - case recoveryStarted = 30 - /// Connection recovery restored a usable session. - case recoverySucceeded = 31 - /// Connection recovery exhausted its current attempt. `b`, when present, - /// is ``DiagnosticFailureKind``. - case recoveryFailed = 32 - /// The local Iroh endpoint started initialization. - case endpointStarting = 33 - /// The local Iroh endpoint became active. - case endpointActive = 34 - /// The local Iroh endpoint stopped. - case endpointStopped = 35 - /// The local Iroh endpoint failed to start or remain active. `b`, when - /// present, is ``DiagnosticFailureKind``. - case endpointFailed = 36 - /// A signed relay-policy refresh started. - case relayPolicyRefreshStarted = 37 - /// A signed relay policy was validated and installed. - case relayPolicyRefreshSucceeded = 38 - /// A relay-policy refresh failed. `b`, when present, is - /// ``DiagnosticFailureKind``. - case relayPolicyRefreshFailed = 39 - /// The selected network path changed. `a` is ``DiagnosticPathKind``. The - /// foreground control session wins over background and feature sessions. - case selectedPathChanged = 40 - /// An established app-transport session closed. `a`, when present, is - /// ``DiagnosticTransportKind``; `b`, when present, is - /// ``DiagnosticFailureKind``; and `c`, when present, is the positive, - /// process-local session ID shared with ``transportSessionLifecycle``. - /// Absence of `b`, or `.none`, means an expected closure. - case sessionClosed = 41 - /// No authenticated route was usable. `b`, when present, is - /// ``DiagnosticFailureKind``. - case routeUnavailable = 42 - /// A bounded retry was scheduled. `ms` is the delay before retry. - case retryScheduled = 43 - /// Same-account or local-route discovery started. - case discoveryStarted = 44 - /// Discovery produced at least one authenticated candidate. - case discoverySucceeded = 45 - /// Discovery failed to produce an authenticated candidate. `b`, when - /// present, is ``DiagnosticFailureKind``. - case discoveryFailed = 46 - /// The host admitted the authenticated client to an RPC session. - case admissionSucceeded = 47 - /// Host admission rejected or failed. `b`, when present, is - /// ``DiagnosticFailureKind``. - case admissionFailed = 48 - /// The remote host identity or secure channel failed authentication. `b`, - /// when present, is ``DiagnosticFailureKind``. - case hostAuthenticationFailed = 49 - /// The authenticated RPC session failed before or after readiness. `b`, - /// when present, is ``DiagnosticFailureKind``. - case rpcFailed = 50 - /// An admitted transport session was established or removed from its local - /// pool. `a` is ``DiagnosticSessionLifecycleKind``, `b` is the local - /// ``CmxTransportSessionPurpose`` raw value, and `c` is a positive, - /// process-local session correlation ID. The event contains no peer or route - /// identity. - case transportSessionLifecycle = 51 -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift deleted file mode 100644 index fbc959b3..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift +++ /dev/null @@ -1,422 +0,0 @@ -public import Foundation -internal import os - -/// A fixed-capacity ring of recent ``DiagnosticEvent`` values with a -/// non-blocking hot-path recorder. -/// -/// The recorder seam is the point of the design: ``record(_:)`` is -/// `nonisolated` and yields onto the current bounded event segment inside one -/// short critical region. There is no per-event `Task { await … }` hop (the -/// cost the string-based `MobileDebugLog.append` pays) and no actor hop on the -/// caller's thread, so it is safe to call from the input and render seams. A -/// single internal consumer `Task` drains ordered event segments and -/// non-droppable clear commands into the ring (the only diagnostic state, -/// held by an inner `actor`), evicting the oldest events past ``capacity``. -/// -/// ``export()`` drains the ring into a compact blob: a one-line header carrying -/// a wall-clock anchor and the build stamp, then one short row per event -/// (`tNanos,code,surface,ms,a,b,c`, omitting absent fields). The blob is small -/// by construction (bounded by ``capacity`` rows of integers). -/// -/// Inject one instance from the app composition root; do not add a `.shared` -/// singleton. -/// -/// ```swift -/// let log = DiagnosticLog() -/// log.record(DiagnosticEvent(.connect)) -/// let blob = await log.export() -/// ``` -public final class DiagnosticLog: Sendable { - /// The maximum number of retained events. Oldest are dropped past this. - public let capacity: Int - - /// The build-identity stamp written into the export header. Exposed so a - /// caller can also carry it as a top-level field when submitting a bundle. - public let buildStamp: String - - /// The component producing this log. This is a fixed integer category, not - /// a device or account identifier. - public let role: DiagnosticRuntimeRole - - /// Synchronously orders record calls against rare clear operations while - /// keeping every event segment bounded by ``capacity``. - private let ingress: Ingress - - /// The inner actor owning the ring buffer and the wall-clock anchor. - private let store: Store - - /// The drain task. Its closure captures only local stream/store values, so - /// deinitialization can finish ingress and let accepted clear commands drain - /// to their acknowledgements without retaining this log. - private let drainTask: Task<Void, Never> - - /// Creates a diagnostic log. - /// - /// - Parameters: - /// - capacity: Maximum retained events; oldest drop past this. Defaults to - /// `4096`. - /// - buildStamp: A short string identifying the running build, written - /// into the export header. Defaults to empty. - /// - role: The fixed runtime category producing this log. Defaults to - /// ``DiagnosticRuntimeRole/unspecified``. - /// - anchorWallNanos: Wall-clock time at construction, in nanoseconds since - /// the Unix epoch, paired with ``anchorMonotonicNanos`` so export can map - /// monotonic event timestamps back to absolute time. Injected for tests; - /// defaults to the current time. - /// - anchorMonotonicNanos: The monotonic clock reading captured at the same - /// instant as ``anchorWallNanos``. Injected for tests; defaults to - /// `DispatchTime.now().uptimeNanoseconds`. - public init( - capacity: Int = 4096, - buildStamp: String = "", - role: DiagnosticRuntimeRole = .unspecified, - anchorWallNanos: UInt64 = UInt64(max(0, Date().timeIntervalSince1970 * 1_000_000_000)), - anchorMonotonicNanos: UInt64 = DispatchTime.now().uptimeNanoseconds - ) { - let capacity = max(1, capacity) - let buildStamp = DiagnosticReport.sanitizeBuildStamp(buildStamp) - self.capacity = capacity - self.buildStamp = buildStamp - self.role = role - let store = Store( - capacity: capacity, - buildStamp: buildStamp, - role: role, - anchorWallNanos: anchorWallNanos, - anchorMonotonicNanos: anchorMonotonicNanos - ) - self.store = store - let (commandStream, commandContinuation) = AsyncStream<DrainCommand>.makeStream( - bufferingPolicy: .unbounded - ) - let ingress = Ingress( - capacity: capacity, - commandContinuation: commandContinuation - ) - self.ingress = ingress - self.drainTask = Task { - for await command in commandStream { - switch command { - case let .events(events): - for await event in events { - await store.append(event) - } - case let .clear( - anchorWallNanos, - anchorMonotonicNanos, - nextEvents, - acknowledgement - ): - await store.clear( - anchorWallNanos: anchorWallNanos, - anchorMonotonicNanos: anchorMonotonicNanos - ) - acknowledgement.resume() - for await event in nextEvents { - await store.append(event) - } - } - } - } - } - - deinit { - ingress.finish() - } - - /// Record one event. Non-blocking and safe from any thread. - /// - /// This is the hot-path API. It only yields the value onto the buffered - /// stream; the actual ring write happens on the internal drain task. A burst - /// past the consumer's pace drops the oldest pending events (per - /// `.bufferingNewest`), never the caller. Repeated - /// ``DiagnosticEventCode/selectedPathChanged`` values for the same redacted - /// path class are consumed but not retained, so observer wakeups cannot be - /// mistaken for transport changes. - /// - /// - Parameter event: The event to record. - public nonisolated func record(_ event: DiagnosticEvent) { - ingress.record(event) - } - - /// Snapshot the currently-drained ring and format a compact export blob. - /// - /// Reads whatever the drain task has already moved into the ring; it does not - /// force a flush of events still in flight on the stream (the AsyncStream + - /// drain design is eventually consistent, which is fine for a human-timed - /// submit). The result is small by construction (bounded by ``capacity`` - /// integer rows). Tests that need an exact post-record snapshot await - /// ``processedCount()`` first. - /// - /// - Returns: The UTF-8 encoded compact blob. - public func export() async -> Data { - await store.export() - } - - /// Returns a Codable, privacy-safe snapshot with events in chronological - /// order. Events still pending in the non-blocking stream are not forced to - /// drain; human-triggered exports naturally observe the most recent drained - /// state. - public func snapshot(generatedAt: Date = Date()) async -> DiagnosticReport { - await store.snapshot(generatedAt: generatedAt) - } - - /// Starts a fresh diagnostic session by clearing retained events, resetting - /// the processed count, and capturing a new wall/monotonic clock anchor. - /// - /// Clear rotates to a new bounded event segment and inserts a non-droppable - /// command between the old and new segments. The drain acknowledges the - /// command only after every retained old-segment event has been consumed and - /// the store has reset, so no old event can reappear after this returns. - /// Recording itself remains non-blocking. - public func clear( - anchorWallNanos: UInt64 = UInt64(max(0, Date().timeIntervalSince1970 * 1_000_000_000)), - anchorMonotonicNanos: UInt64 = DispatchTime.now().uptimeNanoseconds - ) async { - await withCheckedContinuation { acknowledgement in - ingress.clear( - anchorWallNanos: anchorWallNanos, - anchorMonotonicNanos: anchorMonotonicNanos, - acknowledgement: acknowledgement - ) - } - } - - /// The current number of retained events. - public func count() async -> Int { - await store.count() - } - - /// The total number of events the drain task has processed in this session. - /// - /// Unlike ``count()`` this never decreases (ring eviction does not lower it), - /// so it is a stable barrier: after recording `n` events a caller can await - /// this reaching `n` to know every recorded event has reached the ring, - /// regardless of capacity. Used by tests to make the async drain - /// deterministic without sleeping. - public func processedCount() async -> Int { - await store.processedCount() - } - - /// The inner actor that owns the ring and renders the export blob. - /// - /// The ring is a fixed-size pre-allocated `[DiagnosticEvent?]` indexed by a - /// `head` cursor and a saturating `filled` count, so both append and - /// eviction are O(1): a new event overwrites the slot at `head` and advances - /// the cursor (no `Array.removeFirst`, which would be O(capacity) per event - /// once full and would starve the drain task during the exact lag bursts this - /// log captures). - private enum DrainCommand: Sendable { - case events(AsyncStream<DiagnosticEvent>) - case clear( - anchorWallNanos: UInt64, - anchorMonotonicNanos: UInt64, - nextEvents: AsyncStream<DiagnosticEvent>, - acknowledgement: CheckedContinuation<Void, Never> - ) - } - - /// Serializes event-segment rotation without suspending callers. Event - /// segments use `.bufferingNewest(capacity)` and therefore stay bounded; - /// the command stream is unbounded only for rare clear controls, which must - /// never be evicted by diagnostic traffic. - private final class Ingress: Sendable { - private struct State: Sendable { - let capacity: Int - let commandContinuation: AsyncStream<DrainCommand>.Continuation - var eventContinuation: AsyncStream<DiagnosticEvent>.Continuation? - var isFinished = false - } - - private enum ClearEnqueueResult: Sendable { - case enqueued(previous: AsyncStream<DiagnosticEvent>.Continuation?) - case terminated( - previous: AsyncStream<DiagnosticEvent>.Continuation?, - next: AsyncStream<DiagnosticEvent>.Continuation - ) - } - - // lint:allow lock - record is synchronous by contract. The critical - // region only selects/yields a value or rotates stream continuations; - // no async work runs while the lock is held. - private let state: OSAllocatedUnfairLock<State> - - init( - capacity: Int, - commandContinuation: AsyncStream<DrainCommand>.Continuation - ) { - let (events, eventContinuation) = Self.makeEventSegment(capacity: capacity) - self.state = OSAllocatedUnfairLock(initialState: State( - capacity: capacity, - commandContinuation: commandContinuation, - eventContinuation: eventContinuation - )) - commandContinuation.yield(.events(events)) - } - - func record(_ event: DiagnosticEvent) { - state.withLock { state in - guard !state.isFinished else { return } - state.eventContinuation?.yield(event) - } - } - - func clear( - anchorWallNanos: UInt64, - anchorMonotonicNanos: UInt64, - acknowledgement: CheckedContinuation<Void, Never> - ) { - let result: ClearEnqueueResult = state.withLock { state in - guard !state.isFinished else { - let (_, next) = Self.makeEventSegment(capacity: state.capacity) - return .terminated(previous: nil, next: next) - } - - let previous = state.eventContinuation - let (events, continuation) = Self.makeEventSegment(capacity: state.capacity) - let yieldResult = state.commandContinuation.yield(.clear( - anchorWallNanos: anchorWallNanos, - anchorMonotonicNanos: anchorMonotonicNanos, - nextEvents: events, - acknowledgement: acknowledgement - )) - switch yieldResult { - case .enqueued: - state.eventContinuation = continuation - return .enqueued(previous: previous) - case .dropped, .terminated: - state.isFinished = true - state.eventContinuation = nil - return .terminated(previous: previous, next: continuation) - @unknown default: - state.isFinished = true - state.eventContinuation = nil - return .terminated(previous: previous, next: continuation) - } - } - switch result { - case .enqueued(let previous): - previous?.finish() - case let .terminated(previous, next): - previous?.finish() - next.finish() - acknowledgement.resume() - } - } - - func finish() { - let continuations: ( - AsyncStream<DiagnosticEvent>.Continuation?, - AsyncStream<DrainCommand>.Continuation - )? = state.withLock { state in - guard !state.isFinished else { return nil } - state.isFinished = true - let eventContinuation = state.eventContinuation - state.eventContinuation = nil - return (eventContinuation, state.commandContinuation) - } - continuations?.0?.finish() - continuations?.1.finish() - } - - private static func makeEventSegment( - capacity: Int - ) -> (AsyncStream<DiagnosticEvent>, AsyncStream<DiagnosticEvent>.Continuation) { - AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(capacity)) - } - } - - private actor Store { - private var slots: [DiagnosticEvent?] - private var head = 0 - private var filled = 0 - private var totalProcessed = 0 - private var selectedPathKind: DiagnosticPathKind? - private let capacity: Int - private let buildStamp: String - private let role: DiagnosticRuntimeRole - private var anchorWallNanos: UInt64 - private var anchorMonotonicNanos: UInt64 - - init( - capacity: Int, - buildStamp: String, - role: DiagnosticRuntimeRole, - anchorWallNanos: UInt64, - anchorMonotonicNanos: UInt64 - ) { - // A zero/negative capacity would make a 0-length ring; clamp to 1 so - // append always has a slot. - let clamped = max(1, capacity) - self.capacity = clamped - self.buildStamp = buildStamp - self.role = role - self.anchorWallNanos = anchorWallNanos - self.anchorMonotonicNanos = anchorMonotonicNanos - self.slots = Array(repeating: nil, count: clamped) - } - - func append(_ event: DiagnosticEvent) { - totalProcessed += 1 - if let nextPathKind = event.diagnosticPathKind { - guard nextPathKind != selectedPathKind else { return } - selectedPathKind = nextPathKind - } - slots[head] = event - head = (head + 1) % capacity - if filled < capacity { - filled += 1 - } - } - - func count() -> Int { - filled - } - - func processedCount() -> Int { - totalProcessed - } - - func clear(anchorWallNanos: UInt64, anchorMonotonicNanos: UInt64) { - slots = Array(repeating: nil, count: capacity) - head = 0 - filled = 0 - totalProcessed = 0 - selectedPathKind = nil - self.anchorWallNanos = anchorWallNanos - self.anchorMonotonicNanos = anchorMonotonicNanos - } - - /// The retained events in chronological order (oldest first). - /// - /// When the ring is full the oldest event sits at `head` (the next write - /// target); when not yet full the oldest is at index 0. Walking `filled` - /// slots from `start` yields them in record order. - private func orderedEvents() -> [DiagnosticEvent] { - let start = filled < capacity ? 0 : head - var result: [DiagnosticEvent] = [] - result.reserveCapacity(filled) - for offset in 0..<filled { - if let event = slots[(start + offset) % capacity] { - result.append(event) - } - } - return result - } - - func snapshot(generatedAt: Date) -> DiagnosticReport { - DiagnosticReport( - role: role, - generatedAt: generatedAt, - anchorWallNanos: anchorWallNanos, - anchorMonotonicNanos: anchorMonotonicNanos, - buildStamp: buildStamp, - events: orderedEvents() - ) - } - - func export() -> Data { - snapshot(generatedAt: Date()).compactExport() - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticReport.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticReport.swift deleted file mode 100644 index 6b4908dd..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticReport.swift +++ /dev/null @@ -1,381 +0,0 @@ -import Foundation - -/// A bounded, privacy-safe snapshot of recent app-transport diagnostics. -/// -/// The report contains only stable integer enums, timestamps, bounded event -/// payloads, a sanitized build stamp, and the runtime role. It has no fields for -/// addresses, endpoint IDs, account identifiers, relay URLs, tokens, terminal -/// content, or raw error descriptions. -public struct DiagnosticReport: Sendable, Codable, Equatable { - public static let currentSchemaVersion = 1 - public static let maximumEventCount = 4_096 - - /// A deterministic report suitable as a controller's unavailable default. - public static let empty = DiagnosticReport( - role: .unspecified, - generatedAt: Date(timeIntervalSince1970: 0), - anchorWallNanos: 0, - anchorMonotonicNanos: 0, - buildStamp: "", - events: [] - ) - - public let schemaVersion: Int - public let role: DiagnosticRuntimeRole - public let generatedAt: Date - public let anchorWallNanos: UInt64 - public let anchorMonotonicNanos: UInt64 - public let buildStamp: String - /// Events ordered by monotonic timestamp, oldest first. - public let events: [DiagnosticEvent] - - public init( - schemaVersion: Int = DiagnosticReport.currentSchemaVersion, - role: DiagnosticRuntimeRole = .unspecified, - generatedAt: Date = Date(), - anchorWallNanos: UInt64 = 0, - anchorMonotonicNanos: UInt64 = 0, - buildStamp: String = "", - events: [DiagnosticEvent] = [] - ) { - self.schemaVersion = schemaVersion - self.role = role - self.generatedAt = generatedAt - self.anchorWallNanos = anchorWallNanos - self.anchorMonotonicNanos = anchorMonotonicNanos - self.buildStamp = Self.sanitizeBuildStamp(buildStamp) - let retainedEvents = events.suffix(Self.maximumEventCount) - let orderedEvents = retainedEvents - .enumerated() - .sorted { lhs, rhs in - if lhs.element.tNanos == rhs.element.tNanos { - return lhs.offset < rhs.offset - } - return lhs.element.tNanos < rhs.element.tNanos - } - .map(\.element) - self.events = orderedEvents - } - - private enum CodingKeys: String, CodingKey { - case schemaVersion - case role - case generatedAt - case anchorWallNanos - case anchorMonotonicNanos - case buildStamp - case events - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - var eventsContainer = try container.nestedUnkeyedContainer(forKey: .events) - var events: [DiagnosticEvent] = [] - events.reserveCapacity(min(eventsContainer.count ?? 0, Self.maximumEventCount)) - while !eventsContainer.isAtEnd, events.count < Self.maximumEventCount { - events.append(try eventsContainer.decode(DiagnosticEvent.self)) - } - guard eventsContainer.isAtEnd else { - throw DecodingError.dataCorruptedError( - in: eventsContainer, - debugDescription: "Diagnostic report exceeds the maximum event count." - ) - } - self.init( - schemaVersion: try container.decode(Int.self, forKey: .schemaVersion), - role: try container.decode(DiagnosticRuntimeRole.self, forKey: .role), - generatedAt: try container.decode(Date.self, forKey: .generatedAt), - anchorWallNanos: try container.decode(UInt64.self, forKey: .anchorWallNanos), - anchorMonotonicNanos: try container.decode(UInt64.self, forKey: .anchorMonotonicNanos), - buildStamp: try container.decode(String.self, forKey: .buildStamp), - events: events - ) - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(schemaVersion, forKey: .schemaVersion) - try container.encode(role, forKey: .role) - try container.encode(generatedAt, forKey: .generatedAt) - try container.encode(anchorWallNanos, forKey: .anchorWallNanos) - try container.encode(anchorMonotonicNanos, forKey: .anchorMonotonicNanos) - try container.encode(buildStamp, forKey: .buildStamp) - try container.encode(events, forKey: .events) - } - - /// Maps one event's monotonic timestamp onto the report's wall-clock - /// anchor. Empty/default reports have no usable anchor and return `nil`. - public func wallDate(for event: DiagnosticEvent) -> Date? { - wallDate(forMonotonicNanos: event.tNanos) - } - - /// Maps a monotonic timestamp onto the report's wall-clock anchor. - public func wallDate(forMonotonicNanos nanos: UInt64) -> Date? { - guard anchorWallNanos > 0, anchorMonotonicNanos > 0 else { return nil } - let deltaNanos: Double - if nanos >= anchorMonotonicNanos { - deltaNanos = Double(nanos - anchorMonotonicNanos) - } else { - deltaNanos = -Double(anchorMonotonicNanos - nanos) - } - let wallSeconds = Double(anchorWallNanos) / 1_000_000_000 - return Date(timeIntervalSince1970: wallSeconds + (deltaNanos / 1_000_000_000)) - } - - /// The latest event that marks a usable connection/lifecycle milestone. - public var lastSuccessEvent: DiagnosticEvent? { - events.last(where: { $0.code.isDiagnosticSuccess }) - } - - /// The latest event that marks a failed connection/lifecycle milestone. - public var lastFailureEvent: DiagnosticEvent? { - events.last(where: { event in - event.code.isDiagnosticFailure - || event.diagnosticFailureKind.map { $0 != .none } == true - }) - } - - /// Wall-clock time of the most recent successful transport connection. - public var lastTransportConnectionDate: Date? { - guard let event = events.last(where: { $0.code == .transportDialConnected }) else { - return nil - } - return wallDate(for: event) - } - - /// Wall-clock time of the most recent authenticated app connection. A - /// client reports dial/auth/RPC milestones, while a host reports admission, - /// so this helper works for either runtime role. - public var lastConnectionSuccessDate: Date? { - guard let event = events.last(where: { event in - switch event.code { - case .transportDialConnected, .hostAuthenticated, .rpcReady, .admissionSucceeded: - true - default: - false - } - }) else { - return nil - } - return wallDate(for: event) - } - - /// Wall-clock time of the most recent classified failure event. - public var lastFailureDate: Date? { - guard let event = lastFailureEvent else { return nil } - return wallDate(for: event) - } - - /// Privacy-safe category of the most recent failure event. - public var lastFailureKind: DiagnosticFailureKind? { - guard let event = lastFailureEvent else { return nil } - if let kind = event.diagnosticFailureKind, kind != .none { - return kind - } - return event.code.defaultDiagnosticFailureKind - } - - /// Encodes this exact snapshot in the compact, human-shareable v1 format. - /// Building the share payload from the snapshot prevents live events from - /// making the displayed summary and exported timeline disagree. - public func compactExport() -> Data { - var out = "cmuxdiag v1" - out += " anchorWallNs=\(anchorWallNanos)" - out += " anchorMonoNs=\(anchorMonotonicNanos)" - out += " count=\(events.count)" - out += " role=\(role.rawValue)" - if !buildStamp.isEmpty { - out += " build=\(buildStamp)" - } - out += "\n" - for event in events { - out += "\(event.tNanos),\(event.code.rawValue)" - out += ",\(Self.field(event.surface))" - out += ",\(Self.field(event.ms))" - out += ",\(Self.field(event.a))" - out += ",\(Self.field(event.b))" - out += ",\(Self.field(event.c))" - out += "\n" - } - return Data(out.utf8) - } - - /// Removes control characters, path separators, and unbounded caller data - /// from the build stamp before it enters an export. - static func sanitizeBuildStamp(_ value: String) -> String { - var result = "" - result.reserveCapacity(min(96, value.utf8.count)) - for scalar in value.unicodeScalars { - let raw = scalar.value - let isASCIIAlphaNumeric = (48...57).contains(raw) - || (65...90).contains(raw) - || (97...122).contains(raw) - let isAllowedPunctuation = raw == 32 - || raw == 40 - || raw == 41 - || raw == 43 - || raw == 45 - || raw == 46 - || raw == 95 - guard isASCIIAlphaNumeric || isAllowedPunctuation else { continue } - guard result.utf8.count + scalar.utf8.count <= 96 else { break } - result.unicodeScalars.append(scalar) - } - return result - } - - private static func field(_ value: (some BinaryInteger)?) -> String { - guard let value else { return "" } - return String(value) - } -} - -public extension DiagnosticEvent { - /// Transport category carried by a dial event's `a` slot. - var diagnosticTransportKind: DiagnosticTransportKind? { - guard code.isTransportDialEvent, let a else { - return nil - } - return DiagnosticTransportKind(rawValue: a) - } - - /// Failure category carried by a failure event's `b` slot. - var diagnosticFailureKind: DiagnosticFailureKind? { - guard code.carriesDiagnosticFailureKind, - let b - else { - return nil - } - return DiagnosticFailureKind(rawValue: b) - } - - /// Positive process-local correlation ID shared by a dial attempt and its - /// outcome. It is intentionally not stable across launches or devices. - var diagnosticAttemptID: Int? { - guard code.isTransportDialEvent, let c, c > 0 else { return nil } - return c - } - - /// Redacted path class carried by ``DiagnosticEventCode/selectedPathChanged``. - var diagnosticPathKind: DiagnosticPathKind? { - guard code == .selectedPathChanged, let a else { - return nil - } - return DiagnosticPathKind(rawValue: a) - } - - /// Privacy-safe pool transition carried by - /// ``DiagnosticEventCode/transportSessionLifecycle``. - var diagnosticSessionLifecycleKind: DiagnosticSessionLifecycleKind? { - guard code == .transportSessionLifecycle, let a else { return nil } - return DiagnosticSessionLifecycleKind(rawValue: a) - } - - /// Local owner role carried by a transport-session lifecycle event. - var diagnosticSessionPurpose: CmxTransportSessionPurpose? { - guard code == .transportSessionLifecycle, - let b, - let raw = UInt8(exactly: b) else { return nil } - return CmxTransportSessionPurpose(rawValue: raw) - } - - /// Positive process-local session correlation ID. This value is not stable - /// across app launches or devices. - var diagnosticSessionID: Int? { - guard code == .transportSessionLifecycle || code == .sessionClosed, - let c, - c > 0 else { return nil } - return c - } -} - -public extension DiagnosticEventCode { - var isTransportDialEvent: Bool { - switch self { - case .transportDialStarted, .transportDialConnected, .transportDialFailed: - true - default: - false - } - } - - var isDiagnosticSuccess: Bool { - switch self { - case .pairOk, - .transportDialConnected, - .hostAuthenticated, - .rpcReady, - .recoverySucceeded, - .endpointActive, - .relayPolicyRefreshSucceeded, - .discoverySucceeded, - .admissionSucceeded: - true - default: - false - } - } - - var isDiagnosticFailure: Bool { - switch self { - case .pairFail, - .pairUnreachable, - .streamEnded, - .error, - .transportDialFailed, - .recoveryFailed, - .endpointFailed, - .relayPolicyRefreshFailed, - .routeUnavailable, - .discoveryFailed, - .admissionFailed, - .hostAuthenticationFailed, - .rpcFailed: - true - default: - false - } - } - - var carriesDiagnosticFailureKind: Bool { - switch self { - case .transportDialFailed, - .recoveryFailed, - .endpointFailed, - .relayPolicyRefreshFailed, - .sessionClosed, - .routeUnavailable, - .discoveryFailed, - .admissionFailed, - .hostAuthenticationFailed, - .rpcFailed: - true - default: - false - } - } - - var defaultDiagnosticFailureKind: DiagnosticFailureKind? { - switch self { - case .pairUnreachable: - .offline - case .streamEnded: - .connectionClosed - case .routeUnavailable: - .noRoute - case .pairFail, - .error, - .transportDialFailed, - .recoveryFailed, - .endpointFailed, - .relayPolicyRefreshFailed, - .discoveryFailed, - .admissionFailed, - .hostAuthenticationFailed, - .rpcFailed: - .unknown - default: - nil - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift deleted file mode 100644 index b19b7d81..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift +++ /dev/null @@ -1,206 +0,0 @@ -import Foundation - -/// The app transport involved in a diagnostic event. -/// -/// Raw values are stable export vocabulary. Append new cases; never renumber -/// an existing case. -public enum DiagnosticTransportKind: Int, Sendable, Codable, CaseIterable { - case unknown = 0 - case iroh = 1 - case tailscale = 2 - case websocket = 3 - case debugLoopback = 4 - - /// Maps a pairing-route transport without preserving its address or other - /// route metadata. - public init(_ kind: CmxAttachTransportKind) { - switch kind { - case .iroh: - self = .iroh - case .tailscale: - self = .tailscale - case .websocket: - self = .websocket - case .debugLoopback: - self = .debugLoopback - } - } -} - -public extension CmxAttachTransportKind { - /// A privacy-safe integer category suitable for diagnostic payloads. - var diagnosticTransportKind: DiagnosticTransportKind { - DiagnosticTransportKind(self) - } -} - -/// A stable, privacy-safe classification for connection failures. -/// -/// This vocabulary intentionally excludes raw error text, addresses, endpoint -/// IDs, account data, and provider responses. Unknown errors remain -/// ``unknown`` instead of being serialized as strings. -public enum DiagnosticFailureKind: Int, Sendable, Codable, CaseIterable { - case none = 0 - case offline = 1 - case timedOut = 2 - case connectionRefused = 3 - case hostUnreachable = 4 - case permissionDenied = 5 - case dnsFailed = 6 - case secureChannelFailed = 7 - case unsupportedRoute = 8 - case noRoute = 9 - case credentialUnavailable = 10 - case policyUnavailable = 11 - case endpointUnavailable = 12 - case identityMismatch = 13 - case admissionDenied = 14 - case authorizationFailed = 15 - case accountMismatch = 16 - case protocolViolation = 17 - case connectionClosed = 18 - case superseded = 19 - case cancelled = 20 - case unknown = 255 - - /// Reduces a typed or system error to the bounded diagnostic vocabulary. - /// - /// Domain-specific errors should conform to ``DiagnosticFailureProviding`` - /// so their mapping stays close to the source. The fallback recognizes only - /// stable Foundation/POSIX codes and never retains the error's description. - public static func classify(_ error: any Error) -> DiagnosticFailureKind { - if let providing = error as? any DiagnosticFailureProviding { - return providing.diagnosticFailureKind - } - if error is CancellationError { - return .cancelled - } - - let error = error as NSError - if error.domain == NSURLErrorDomain { - switch error.code { - case NSURLErrorNotConnectedToInternet, - NSURLErrorInternationalRoamingOff, - NSURLErrorDataNotAllowed: - return .offline - case NSURLErrorTimedOut: - return .timedOut - case NSURLErrorCannotConnectToHost: - return .connectionRefused - case NSURLErrorCannotFindHost, - NSURLErrorDNSLookupFailed: - return .dnsFailed - case NSURLErrorSecureConnectionFailed, - NSURLErrorServerCertificateHasBadDate, - NSURLErrorServerCertificateUntrusted, - NSURLErrorServerCertificateHasUnknownRoot, - NSURLErrorServerCertificateNotYetValid, - NSURLErrorClientCertificateRejected, - NSURLErrorClientCertificateRequired: - return .secureChannelFailed - case NSURLErrorUserAuthenticationRequired: - return .authorizationFailed - case NSURLErrorNetworkConnectionLost: - return .connectionClosed - case NSURLErrorCancelled: - return .cancelled - default: - return .unknown - } - } - - if error.domain == NSPOSIXErrorDomain { - switch error.code { - case Int(POSIXErrorCode.ECONNREFUSED.rawValue): - return .connectionRefused - case Int(POSIXErrorCode.EHOSTUNREACH.rawValue), - Int(POSIXErrorCode.ENETUNREACH.rawValue): - return .hostUnreachable - case Int(POSIXErrorCode.ETIMEDOUT.rawValue): - return .timedOut - case Int(POSIXErrorCode.EACCES.rawValue), - Int(POSIXErrorCode.EPERM.rawValue): - return .permissionDenied - case Int(POSIXErrorCode.ECONNRESET.rawValue), - Int(POSIXErrorCode.EPIPE.rawValue), - Int(POSIXErrorCode.ENOTCONN.rawValue): - return .connectionClosed - case Int(POSIXErrorCode.ECANCELED.rawValue): - return .cancelled - default: - return .unknown - } - } - - return .unknown - } -} - -/// Adopted by transport and policy errors that can provide a safe failure -/// category without exporting their raw associated values or description. -public protocol DiagnosticFailureProviding: Error, Sendable { - var diagnosticFailureKind: DiagnosticFailureKind { get } -} - -/// The network path selected underneath an app transport. -public enum DiagnosticPathKind: Int, Sendable, Codable, CaseIterable { - case unknown = 0 - case direct = 1 - case relay = 2 - case privateNetwork = 3 - case loopback = 4 - - /// Redacts a live Iroh path to its connection class. Managed and custom - /// relay metadata intentionally collapse to the same ``relay`` value. - public init(_ path: CmxIrohSelectedTransportPath) { - switch path { - case .unavailable: - self = .unknown - case .direct: - self = .direct - case .privateNetwork: - self = .privateNetwork - case .managedRelay, .customRelay: - self = .relay - } - } -} - -/// Why an admitted transport session entered or left its local pool. -/// -/// Raw values are stable export vocabulary. The cases identify only local -/// lifecycle ownership, never a peer, endpoint, address, account, or raw error. -public enum DiagnosticSessionLifecycleKind: Int, Sendable, Codable, CaseIterable { - /// A newly authenticated session entered the pool. - case established = 1 - /// The RPC owner intentionally relinquished its control stream. - case controlOwnerReleased = 2 - /// The RPC control reader failed and relinquished ownership. - case controlReadFailed = 3 - /// The RPC control writer failed and relinquished ownership. - case controlWriteFailed = 4 - /// The transport reported that its peer connection closed. - case remoteClosed = 5 - /// A caller found a cached session already closed before its watcher ran. - case closedSessionEvicted = 6 - /// An application-lane operation found the shared connection closed. - case applicationLaneFailed = 7 - /// The account-scoped runtime stopped. - case runtimeDeactivated = 8 - /// The runtime generation changed and replaced its prior sessions. - case runtimeReconfigured = 9 - /// A caller explicitly invalidated one exact peer session. - case explicitlyInvalidated = 10 -} - -/// Which component produced a diagnostic report. -public enum DiagnosticRuntimeRole: Int, Sendable, Codable, CaseIterable { - case unspecified = 0 - case mobileClient = 1 - case macHost = 2 - case broker = 3 - case relay = 4 - - /// Source-level spelling used by the current Apple mobile composition. - public static let iosClient = DiagnosticRuntimeRole.mobileClient -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/InputResponderIdentity.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/InputResponderIdentity.swift deleted file mode 100644 index c4347813..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/InputResponderIdentity.swift +++ /dev/null @@ -1,28 +0,0 @@ -/// A compact integer identity for the view that owns the keyboard's first -/// responder on the iOS terminal input path. -/// -/// ``DiagnosticEvent`` carries only integer payloads (no allocated strings), so -/// the first-responder *class* is encoded as one of these small raw values and -/// decoded back to a human-readable name by `scripts/decode-ios-diagnostic.py`. -/// The composer-dock instrumentation stamps this into the payload slots of the -/// ``DiagnosticEventCode/composerActiveTransition`` and -/// ``DiagnosticEventCode/composerKeyboardToggleWhilePresented`` events so a -/// captured trace shows *which* view actually holds first responder when the -/// composer opens, closes, or survives a keyboard toggle. -public enum InputResponderIdentity: Int, Sendable, Codable, CaseIterable { - /// No first responder, or it could not be resolved. - case none = 0 - /// The expected terminal keyboard proxy (`TerminalInputTextView`). The - /// keyboard is driving the view we instrument. - case terminalInputProxy = 1 - /// The Metal/IOSurface terminal surface itself (`GhosttySurfaceView`). - case ghosttySurface = 2 - /// A `UITextField` (e.g. an unexpected SwiftUI/text field stealing focus). - case uiTextField = 3 - /// A `UITextView`. - case uiTextView = 4 - /// Some other `UIResponder` subclass not in this list. The decoder pairs this - /// with the human-readable class name carried in the companion string log - /// (`anchormux`) for the same event. - case other = 9 -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileHostRPCWorkQuota.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileHostRPCWorkQuota.swift deleted file mode 100644 index dd78f1da..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileHostRPCWorkQuota.swift +++ /dev/null @@ -1,56 +0,0 @@ -/// Admission policy for decoded mobile RPC frames awaiting a response. -/// -/// The host evaluates this policy and inserts the admitted task within the same -/// actor turn. This bounds both request-handler tasks and waiters on the shared -/// serialized response writer for every byte transport. -public struct MobileHostRPCWorkQuota: Sendable { - /// Keeps useful request concurrency while bounding per-connection tasks. - public static let recommendedMaximumConcurrentRequestCount = 16 - - /// One connection may retain at most one protocol-sized frame of decoded - /// request data across all in-flight handlers. - public static let recommendedMaximumAggregateFrameByteCount = - MobileSyncFrameCodec.defaultMaximumFrameByteCount - - public let maximumConcurrentRequestCount: Int - public let maximumAggregateFrameByteCount: Int - - public init( - maximumConcurrentRequestCount: Int = Self - .recommendedMaximumConcurrentRequestCount, - maximumAggregateFrameByteCount: Int = Self - .recommendedMaximumAggregateFrameByteCount - ) { - precondition(maximumConcurrentRequestCount > 0) - precondition(maximumAggregateFrameByteCount > 0) - self.maximumConcurrentRequestCount = maximumConcurrentRequestCount - self.maximumAggregateFrameByteCount = maximumAggregateFrameByteCount - } - - /// Returns whether one more decoded frame fits both request budgets. - /// - /// Subtraction from the remaining budget avoids overflowing when evaluating - /// malformed or defensive caller-provided counts. - public func allowsAdmission<ActiveFrameByteCounts: Sequence>( - frameByteCount: Int, - activeFrameByteCounts: ActiveFrameByteCounts - ) -> Bool where ActiveFrameByteCounts.Element == Int { - guard frameByteCount >= 0, - frameByteCount <= maximumAggregateFrameByteCount else { - return false - } - - var activeRequestCount = 0 - var remainingByteCount = maximumAggregateFrameByteCount - frameByteCount - for activeFrameByteCount in activeFrameByteCounts { - activeRequestCount += 1 - guard activeRequestCount < maximumConcurrentRequestCount, - activeFrameByteCount >= 0, - activeFrameByteCount <= remainingByteCount else { - return false - } - remainingByteCount -= activeFrameByteCount - } - return true - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileIOSBuildScope.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileIOSBuildScope.swift deleted file mode 100644 index 7f626e07..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileIOSBuildScope.swift +++ /dev/null @@ -1,79 +0,0 @@ -public import Foundation - -/// Identifies one tagged iOS development build. -/// -/// The canonical tag owns the iOS saved-Mac and backup partitions. The Mac app -/// instance used for route authority is resolved separately. Authentication -/// environment changes do not relax this tag boundary. Stable and untagged iOS -/// builds have no scope, so they keep the official build policy instead of -/// manufacturing a tagged identity. -public struct MobileIOSBuildScope: Sendable, Equatable { - private static let serializedScopeVersion = "v2" - - /// The canonical iOS development tag. - public let value: String - - /// Creates a tagged-build scope, or returns `nil` for stable/untagged input. - /// - /// - Parameter rawValue: The canonical development tag. - public init?(_ rawValue: String?) { - let trimmed = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty, trimmed.lowercased() != "default" else { return nil } - self.value = trimmed - } - - /// Resolves the scope owned by the running iOS app bundle. - /// - /// - Parameters: - /// - infoDictionary: The app bundle metadata containing `CMUXDevTag`. - /// - bundleIdentifier: The installed iOS app's bundle identifier. - /// - Returns: A tag scope for a development build, or `nil` for stable. - public static func current( - infoDictionary: [String: Any]? = Bundle.main.infoDictionary, - bundleIdentifier: String? = Bundle.main.bundleIdentifier - ) -> MobileIOSBuildScope? { - let prefix = "dev.cmux.ios." - if let bundleIdentifier, - bundleIdentifier.hasPrefix(prefix), - let scope = MobileIOSBuildScope(String(bundleIdentifier.dropFirst(prefix.count))) { - return scope - } - - if let value = infoDictionary?["CMUXDevTag"] as? String, - let scope = MobileIOSBuildScope(value) { - return scope - } - - return nil - } - - /// A filesystem- and header-safe encoding of ``value``. - public var storageComponent: String { - Data(value.utf8) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - /// The paired-Mac backup client scope shared with the matching Mac build. - /// - /// The version is part of the storage namespace. The unversioned namespace - /// was populated from shared device-level data by older development builds, - /// so reading it would reintroduce cross-build routes after an upgrade. - public var serializedScope: String { - "ios:\(Self.serializedScopeVersion):\(storageComponent)" - } - - /// Presentation name for a Mac shown by this tagged iOS build. - /// - /// The suffix comes from the running iOS bundle, so restored or offline - /// device-level records remain distinguishable before a host handshake. - /// - /// - Parameter baseName: The stable physical-device name. - /// - Returns: The name suffixed with the development tag exactly once. - public func computerDisplayName(_ baseName: String) -> String { - let suffix = " (\(value))" - return baseName.hasSuffix(suffix) ? baseName : baseName + suffix - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift deleted file mode 100644 index 139c8e1a..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileSyncProtocol.swift +++ /dev/null @@ -1,272 +0,0 @@ -import Foundation - -/// Shared default constants for the mobile sync protocol. -public struct CmxMobileDefaults { - private init() {} - - /// The default daemon host port mobile clients dial when none is supplied. - public static let defaultHostPort = 58_465 - /// Shared Mac/iOS pairing compatibility level. Bump this only when current - /// clients can pair but may behave incorrectly without explicit user approval. - public static let pairingCompatibilityVersion = 1 -} - -public enum CmxAttachTransportKind: String, Codable, Sendable { - case tailscale - case iroh - case websocket - case debugLoopback = "debug_loopback" -} - -public typealias MobileSyncTransportKind = CmxAttachTransportKind - -public enum MobileSyncPairingPayloadError: Error, Equatable, Sendable { - case unsupportedVersion(Int) - case emptyHost - case invalidPort(Int) - case expired - case forbiddenSecretField(String) - case invalidURL - case invalidPayloadEncoding - /// A scanned/pasted pairing code only offered loopback routes. A QR or - /// deep link pointing at `127.0.0.1` would make the phone dial itself, - /// so it is rejected with a clear error instead of a doomed connect; - /// loopback pairing is reserved for the dev-injected attach URL path. - case loopbackRouteRejected - /// A pairing/attach URL whose grammar version (`v=`) is newer than this - /// build understands. The associated value is the version read off the URL. - /// Surfaced distinctly so the user is told to update the app rather than - /// shown the generic "not a valid code" copy. - case unrecognizedURLVersion(Int) -} - -public struct MobileSyncPairingPayload: Equatable, Sendable, Codable { - public static let currentVersion = 1 - private static let validationDateUserInfoKey = CodingUserInfoKey( - rawValue: "dev.cmux.mobileSyncPairingPayload.validationDate" - )! - - public let version: Int - public let macDeviceID: String - public let macDisplayName: String? - public let host: String - public let port: Int - public let expiresAt: Date - public let transport: MobileSyncTransportKind - - public init( - version: Int = Self.currentVersion, - macDeviceID: String, - macDisplayName: String?, - host: String, - port: Int, - expiresAt: Date, - transport: MobileSyncTransportKind - ) throws { - self.version = version - self.macDeviceID = cmxCanonicalDeviceID(macDeviceID) - self.macDisplayName = macDisplayName - self.host = host - self.port = port - self.expiresAt = expiresAt - self.transport = transport - try validate(now: Date()) - } - - public init(from decoder: Decoder) throws { - let keyed = try decoder.container(keyedBy: DynamicCodingKey.self) - for key in keyed.allKeys { - let normalizedKey = key.stringValue.lowercased() - if Self.forbiddenSecretKeyMarkers.contains(where: { normalizedKey.contains($0) }) { - throw MobileSyncPairingPayloadError.forbiddenSecretField(key.stringValue) - } - } - - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decode(Int.self, forKey: .version) - macDeviceID = cmxCanonicalDeviceID( - try container.decode(String.self, forKey: .macDeviceID) - ) - macDisplayName = try container.decodeIfPresent(String.self, forKey: .macDisplayName) - host = try container.decode(String.self, forKey: .host) - port = try container.decode(Int.self, forKey: .port) - expiresAt = try container.decode(Date.self, forKey: .expiresAt) - transport = try container.decode(MobileSyncTransportKind.self, forKey: .transport) - let now = decoder.userInfo[Self.validationDateUserInfoKey] as? Date ?? Date() - try validate(now: now) - } - - public func validate(now: Date = Date()) throws { - guard version == Self.currentVersion else { - throw MobileSyncPairingPayloadError.unsupportedVersion(version) - } - guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw MobileSyncPairingPayloadError.emptyHost - } - guard (1...65535).contains(port) else { - throw MobileSyncPairingPayloadError.invalidPort(port) - } - guard expiresAt > now else { - throw MobileSyncPairingPayloadError.expired - } - } - - public func encodedURL() throws -> URL { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(self) - let payload = Self.base64URLEncode(data) - guard let url = URL(string: "\(CmxPairingURLScheme.current)://pair?v=\(version)&payload=\(payload)") else { - throw MobileSyncPairingPayloadError.invalidURL - } - return url - } - - public static func decodeURL(_ url: URL, now: Date = Date()) throws -> MobileSyncPairingPayload { - guard CmxPairingURLScheme.isPairingScheme(url.scheme), - url.host == "pair", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let encodedPayload = components.queryItems?.first(where: { $0.name == "payload" })?.value, - let data = base64URLDecode(encodedPayload) else { - throw MobileSyncPairingPayloadError.invalidURL - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - decoder.userInfo[validationDateUserInfoKey] = now - let payload = try decoder.decode(MobileSyncPairingPayload.self, from: data) - return payload - } - - private enum CodingKeys: String, CodingKey { - case version - case macDeviceID = "mac_device_id" - case macDisplayName = "mac_display_name" - case host - case port - case expiresAt = "expires_at" - case transport - } - - private static let forbiddenSecretKeyMarkers: Set<String> = [ - "auth", - "authorization", - "bearer", - "credential", - "jwt", - "password", - "secret", - "token", - ] - - private static func base64URLEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - private static func base64URLDecode(_ value: String) -> Data? { - var base64 = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padding = base64.count % 4 - if padding > 0 { - base64.append(String(repeating: "=", count: 4 - padding)) - } - return Data(base64Encoded: base64) - } -} - -public enum MobileSyncFrameCodecError: Error, Equatable, Sendable { - case frameTooLarge(Int) - case tooManyFrames(Int) -} - -/// Length-prefixed frame codec for the mobile sync wire protocol. -public struct MobileSyncFrameCodec { - private init() {} - - public static let headerByteCount = 4 - public static let defaultMaximumFrameByteCount = 8 * 1024 * 1024 - public static let defaultMaximumDecodedFrameCount = 256 - - public static func encodeFrame(_ payload: Data) throws -> Data { - guard payload.count <= defaultMaximumFrameByteCount else { - throw MobileSyncFrameCodecError.frameTooLarge(payload.count) - } - var length = UInt32(payload.count).bigEndian - var frame = Data(bytes: &length, count: headerByteCount) - frame.append(payload) - return frame - } - - public static func decodeFrames( - from buffer: inout Data, - maximumFrameByteCount: Int = defaultMaximumFrameByteCount, - maximumDecodedFrameCount: Int = defaultMaximumDecodedFrameCount - ) throws -> [Data] { - precondition(maximumFrameByteCount >= 0) - precondition(maximumDecodedFrameCount > 0) - var frames: [Data] = [] - frames.reserveCapacity(min(maximumDecodedFrameCount, 16)) - var consumedByteCount = 0 - defer { - if consumedByteCount > 0 { - buffer.removeSubrange( - buffer.startIndex..<buffer.index( - buffer.startIndex, - offsetBy: consumedByteCount - ) - ) - } - } - - while buffer.count - consumedByteCount >= headerByteCount { - let frameStart = buffer.index( - buffer.startIndex, - offsetBy: consumedByteCount - ) - let headerEnd = buffer.index( - frameStart, - offsetBy: headerByteCount - ) - let length = buffer[frameStart..<headerEnd].reduce(UInt32(0)) { partial, byte in - (partial << 8) | UInt32(byte) - } - let payloadLength = Int(length) - guard payloadLength <= maximumFrameByteCount else { - throw MobileSyncFrameCodecError.frameTooLarge(payloadLength) - } - guard buffer.count - consumedByteCount >= headerByteCount + payloadLength else { - break - } - guard frames.count < maximumDecodedFrameCount else { - throw MobileSyncFrameCodecError.tooManyFrames( - maximumDecodedFrameCount - ) - } - let payloadStart = headerEnd - let payloadEnd = buffer.index( - payloadStart, - offsetBy: payloadLength - ) - frames.append(buffer.subdata(in: payloadStart..<payloadEnd)) - consumedByteCount += headerByteCount + payloadLength - } - return frames - } -} - -private struct DynamicCodingKey: CodingKey { - var stringValue: String - var intValue: Int? - - init(stringValue: String) { - self.stringValue = stringValue - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift deleted file mode 100644 index ce7ecef8..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift +++ /dev/null @@ -1,529 +0,0 @@ -import Foundation - -public enum MobileTerminalRenderGridError: Error, Equatable, Sendable { - case invalidFormat(String) - case invalidDimensions(columns: Int, rows: Int) - case invalidRow(Int) - case invalidColumn(Int) - case invalidCursor(row: Int, column: Int) - case invalidStyleID(Int) - case invalidSpanWidth(row: Int, column: Int, width: Int, columns: Int) -} - -public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable { - public static let currentFormat = "cmux.render-grid.v1" - - public var format: String - public var surfaceID: String - public var stateSeq: UInt64 - public var columns: Int - public var rows: Int - public var cursor: Cursor? - public var full: Bool - public var clearedRows: [Int] - public var styles: [Style] - public var rowSpans: [RowSpan] - /// Which screen the snapshot represents. The alternate screen is restored - /// with `?1049h` so a TUI keeps real alt-screen semantics (exiting it - /// returns to the primary screen) instead of being painted onto primary. - public var activeScreen: Screen - /// Non-default DEC/ANSI modes to restore on a full snapshot (mouse - /// tracking, bracketed paste, application cursor keys, autowrap, etc.). - /// Delta frames keep only mode state needed to restore after replay-time - /// coordinate normalization. - public var modes: [ModeSetting] - /// Raw default foreground/background colors for OSC 10/11 replay. DEC - /// reverse-video remains represented separately in ``modes``. Legacy - /// producers omit configured defaults. The cursor value remains an optional - /// dynamic OSC 12 override. - public var terminalForeground: String? - public var terminalBackground: String? - public var terminalCursorColor: String? - /// The Mac terminal's resolved theme when this is a full snapshot. - /// - /// Mobile chrome uses this value to match the mirrored surface. Delta - /// frames omit it because the most recent full snapshot remains - /// authoritative until another full snapshot replaces it. - public var terminalTheme: TerminalTheme? - /// The Mac terminal's raw configuration defaults when this is a full snapshot. - /// - /// Unlike ``terminalTheme``, these colors do not include OSC overrides or - /// DEC reverse-video. A mirror installs them as its Ghostty configuration so - /// OSC reset commands restore the same defaults as the Mac. - public var terminalConfigTheme: TerminalTheme? - /// Monotonic producer order for full-frame theme metadata. - public var terminalThemeRevision: UInt64? - /// Count of scrollback lines carried in ``scrollbackSpans`` (rows above the - /// visible viewport, oldest first). Only meaningful on a full primary-screen - /// snapshot; the alternate screen has no scrollback. - public var scrollbackRows: Int - /// Styled spans for the scrollback lines, row index `0..<scrollbackRows` - /// (oldest first). Reuses ``styles`` by `styleID`. - public var scrollbackSpans: [RowSpan] - - public init( - format: String = Self.currentFormat, - surfaceID: String, - stateSeq: UInt64, - columns: Int, - rows: Int, - cursor: Cursor? = nil, - full: Bool = true, - clearedRows: [Int] = [], - styles: [Style] = [.default], - rowSpans: [RowSpan], - activeScreen: Screen = .primary, - modes: [ModeSetting] = [], - terminalForeground: String? = nil, - terminalBackground: String? = nil, - terminalCursorColor: String? = nil, - terminalTheme: TerminalTheme? = nil, - terminalConfigTheme: TerminalTheme? = nil, - terminalThemeRevision: UInt64? = nil, - scrollbackRows: Int = 0, - scrollbackSpans: [RowSpan] = [] - ) throws { - guard format == Self.currentFormat else { - throw MobileTerminalRenderGridError.invalidFormat(format) - } - guard columns > 0, rows > 0 else { - throw MobileTerminalRenderGridError.invalidDimensions(columns: columns, rows: rows) - } - if let cursor, - !(0..<rows).contains(cursor.row) || !(0..<columns).contains(cursor.column) { - throw MobileTerminalRenderGridError.invalidCursor(row: cursor.row, column: cursor.column) - } - for row in clearedRows { - guard (0..<rows).contains(row) else { - throw MobileTerminalRenderGridError.invalidRow(row) - } - } - let resolvedStyles = styles.isEmpty ? [.default] : styles - let styleIDs = Set(resolvedStyles.map(\.id)) - for span in rowSpans { - guard (0..<rows).contains(span.row) else { - throw MobileTerminalRenderGridError.invalidRow(span.row) - } - guard (0..<columns).contains(span.column) else { - throw MobileTerminalRenderGridError.invalidColumn(span.column) - } - guard styleIDs.contains(span.styleID) else { - throw MobileTerminalRenderGridError.invalidStyleID(span.styleID) - } - let width = span.gridCellWidth - guard width > 0, span.column + width <= columns else { - throw MobileTerminalRenderGridError.invalidSpanWidth( - row: span.row, - column: span.column, - width: width, - columns: columns - ) - } - } - let resolvedScrollbackRows = max(0, scrollbackRows) - for span in scrollbackSpans { - guard (0..<resolvedScrollbackRows).contains(span.row) else { - throw MobileTerminalRenderGridError.invalidRow(span.row) - } - guard (0..<columns).contains(span.column) else { - throw MobileTerminalRenderGridError.invalidColumn(span.column) - } - guard styleIDs.contains(span.styleID) else { - throw MobileTerminalRenderGridError.invalidStyleID(span.styleID) - } - let width = span.gridCellWidth - guard width > 0, span.column + width <= columns else { - throw MobileTerminalRenderGridError.invalidSpanWidth( - row: span.row, - column: span.column, - width: width, - columns: columns - ) - } - } - self.format = format - self.surfaceID = surfaceID - self.stateSeq = stateSeq - self.columns = columns - self.rows = rows - self.cursor = cursor - self.full = full - self.clearedRows = full ? [] : Array(Set(clearedRows).sorted()) - self.styles = resolvedStyles - self.rowSpans = rowSpans - self.activeScreen = activeScreen - self.modes = modes - self.terminalForeground = terminalForeground - self.terminalBackground = terminalBackground - self.terminalCursorColor = terminalCursorColor - self.terminalTheme = full ? terminalTheme?.validatedOrDefault() : nil - self.terminalConfigTheme = full ? terminalConfigTheme?.validatedOrDefault() : nil - self.terminalThemeRevision = full ? terminalThemeRevision : nil - self.scrollbackRows = full ? resolvedScrollbackRows : 0 - self.scrollbackSpans = full ? scrollbackSpans : [] - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let format = try container.decode(String.self, forKey: .format) - let surfaceID = try container.decode(String.self, forKey: .surfaceID) - let stateSeq = try container.decode(UInt64.self, forKey: .stateSeq) - let columns = try container.decode(Int.self, forKey: .columns) - let rows = try container.decode(Int.self, forKey: .rows) - let cursor = try container.decodeIfPresent(Cursor.self, forKey: .cursor) - let full = try container.decodeIfPresent(Bool.self, forKey: .full) ?? true - let clearedRows = try container.decodeIfPresent([Int].self, forKey: .clearedRows) ?? [] - let styles = try container.decodeIfPresent([Style].self, forKey: .styles) ?? [.default] - let rowSpans = try container.decode([RowSpan].self, forKey: .rowSpans) - let activeScreen = try container.decodeIfPresent(Screen.self, forKey: .activeScreen) ?? .primary - let modes = try container.decodeIfPresent([ModeSetting].self, forKey: .modes) ?? [] - let terminalForeground = try container.decodeIfPresent(String.self, forKey: .terminalForeground) - let terminalBackground = try container.decodeIfPresent(String.self, forKey: .terminalBackground) - let terminalCursorColor = try container.decodeIfPresent(String.self, forKey: .terminalCursorColor) - let terminalTheme = try container.decodeIfPresent(TerminalTheme.self, forKey: .terminalTheme) - let terminalConfigTheme = try container.decodeIfPresent(TerminalTheme.self, forKey: .terminalConfigTheme) - let terminalThemeRevision = try container.decodeIfPresent(UInt64.self, forKey: .terminalThemeRevision) - let scrollbackRows = try container.decodeIfPresent(Int.self, forKey: .scrollbackRows) ?? 0 - let scrollbackSpans = try container.decodeIfPresent([RowSpan].self, forKey: .scrollbackSpans) ?? [] - try self.init( - format: format, - surfaceID: surfaceID, - stateSeq: stateSeq, - columns: columns, - rows: rows, - cursor: cursor, - full: full, - clearedRows: clearedRows, - styles: styles, - rowSpans: rowSpans, - activeScreen: activeScreen, - modes: modes, - terminalForeground: terminalForeground, - terminalBackground: terminalBackground, - terminalCursorColor: terminalCursorColor, - terminalTheme: terminalTheme, - terminalConfigTheme: terminalConfigTheme, - terminalThemeRevision: terminalThemeRevision, - scrollbackRows: scrollbackRows, - scrollbackSpans: scrollbackSpans - ) - } - - public static func fromPlainRows( - surfaceID: String, - stateSeq: UInt64, - columns: Int, - rows: Int, - text: String, - cursor: Cursor? = nil, - full: Bool = true, - changedRows: Set<Int>? = nil - ) throws -> MobileTerminalRenderGridFrame { - let lines = normalizedRows(from: text, maxRows: rows) - let includedRows = changedRows ?? Set(0..<rows) - let spans = lines.enumerated().compactMap { row, line -> RowSpan? in - guard includedRows.contains(row) else { return nil } - let trimmed = trimmingTrailingGridBlanks(line) - guard !trimmed.isEmpty else { return nil } - let clipped = trimmed.clippedToRenderGridColumns(columns) - guard !clipped.isEmpty else { return nil } - return RowSpan( - row: row, - column: 0, - styleID: 0, - text: clipped - ) - } - return try MobileTerminalRenderGridFrame( - surfaceID: surfaceID, - stateSeq: stateSeq, - columns: columns, - rows: rows, - cursor: cursor, - full: full, - clearedRows: full ? [] : Array(includedRows.sorted()), - rowSpans: spans - ) - } - - public func plainRows() -> [String] { - var rows = Array(repeating: "", count: self.rows) - for span in rowSpans.sorted(by: { lhs, rhs in - lhs.row == rhs.row ? lhs.column < rhs.column : lhs.row < rhs.row - }) { - guard rows.indices.contains(span.row) else { continue } - let currentWidth = rows[span.row].count - if currentWidth < span.column { - rows[span.row].append(String(repeating: " ", count: span.column - currentWidth)) - } - rows[span.row].append(span.text) - let textWidth = span.text.count - let padWidth = max(0, span.gridCellWidth - textWidth) - if padWidth > 0 { - rows[span.row].append(String(repeating: " ", count: padWidth)) - } - } - return rows - } - - /// A per-row signature capturing both text **and resolved styling**, used - /// to detect which rows changed between two full snapshots. - /// - /// Unlike ``plainRows()`` this changes when only a cell's style changes - /// (for example a character typed over a dimmed shell autosuggestion, where - /// the text is identical but the cell flips from faint to normal), so a - /// style-only update is not dropped from the delta. The style is resolved - /// to its visual attributes rather than keyed by ``Style/id``, because the - /// producer reassigns style ids on every export. - public func rowSignatures() -> [String] { - var stylesByID: [Int: Style] = [:] - for style in styles { - stylesByID[style.id] = style - } - var spansByRow: [Int: [RowSpan]] = [:] - for span in rowSpans { - spansByRow[span.row, default: []].append(span) - } - var signatures = Array(repeating: "", count: rows) - for row in 0..<rows { - guard let spans = spansByRow[row] else { continue } - signatures[row] = spans - .sorted { $0.column < $1.column } - .map { span in - let style = stylesByID[span.styleID] ?? .default - return "\(span.column):\(span.gridCellWidth):\(Self.styleSignature(style)):\(span.text)" - } - .joined(separator: "\u{1F}") - } - return signatures - } - - private static func styleSignature(_ style: Style) -> String { - let flags = [ - style.bold, style.faint, style.italic, style.underline, style.blink, - style.inverse, style.invisible, style.strikethrough, style.overline, - ].map { $0 ? "1" : "0" }.joined() - let foregroundSource = style.foregroundSource?.rawValue ?? "legacy" - let backgroundSource = style.backgroundSource?.rawValue ?? "legacy" - return "\(style.foreground ?? "-"):\(foregroundSource):\(style.foregroundPaletteIndex ?? -1)/" + - "\(style.background ?? "-"):\(backgroundSource):\(style.backgroundPaletteIndex ?? -1)/\(flags)" - } - - public func filteredRows(_ includedRows: Set<Int>, full: Bool) throws -> MobileTerminalRenderGridFrame { - try MobileTerminalRenderGridFrame( - surfaceID: surfaceID, - stateSeq: stateSeq, - columns: columns, - rows: rows, - cursor: cursor, - full: full, - clearedRows: full ? [] : Array(includedRows.sorted()), - styles: styles, - rowSpans: rowSpans.filter { includedRows.contains($0.row) }, - // Deltas only carry autowrap; DECOM needs a full snapshot because - // restoring it homes the cursor and requires scroll-region state. - activeScreen: activeScreen, - modes: full ? modes : modes.filter(\.isDECAutowrapMode), - terminalForeground: full ? terminalForeground : nil, - terminalBackground: full ? terminalBackground : nil, - terminalCursorColor: full ? terminalCursorColor : nil, - terminalTheme: full ? terminalTheme : nil, - terminalConfigTheme: full ? terminalConfigTheme : nil, - terminalThemeRevision: full ? terminalThemeRevision : nil, - scrollbackRows: full ? scrollbackRows : 0, - scrollbackSpans: full ? scrollbackSpans : [] - ) - } - - public static func normalizedPlainRows(from text: String, maxRows: Int) -> [String] { - normalizedRows(from: text, maxRows: maxRows) - } - - public func jsonObject() throws -> [String: Any] { - let data = try JSONEncoder().encode(self) - guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return [:] - } - return object - } - - public static func decodeJSONObject(_ object: Any) throws -> MobileTerminalRenderGridFrame { - let data = try JSONSerialization.data(withJSONObject: object) - return try JSONDecoder().decode(MobileTerminalRenderGridFrame.self, from: data) - } - - /// Decode a render-grid frame directly from raw JSON data. - /// - /// Equivalent to ``decodeJSONObject(_:)`` for callers that already hold the - /// serialized payload (for example a push-event payload), avoiding a - /// round-trip through `JSONSerialization`. - /// - Parameter data: The JSON-encoded frame. - /// - Returns: The decoded, validated frame. - /// - Throws: A decoding or validation error if the payload is malformed. - public static func decode(_ data: Data) throws -> MobileTerminalRenderGridFrame { - try JSONDecoder().decode(MobileTerminalRenderGridFrame.self, from: data) - } - - /// Alias for ``vtPatchBytes()``; the byte stream both replaces a full - /// screen and patches a delta depending on ``full``. - /// - /// Forwards to ``MobileTerminalRenderGridReplay/replacementBytes()``; the - /// VT synthesizer lives there so this DTO stays a pure value. - public func vtReplacementBytes() -> Data { - MobileTerminalRenderGridReplay(self).replacementBytes() - } - - /// Synthesize a VT byte stream that reproduces this frame when fed to a - /// terminal emulator. - /// - /// A **full** frame is a faithful cold-attach snapshot: it resets the - /// terminal, restores dynamic default colors, repaints scrollback and the - /// visible viewport as a natural scrolling flow, restores the active screen - /// (`?1049h` for the alternate screen), reapplies non-default DEC/ANSI - /// modes, and finally restores the cursor. A **delta** frame normalizes - /// coordinate-affecting modes, then clears and repaints only the changed - /// viewport rows using absolute producer row indexes. - /// - /// Forwards to ``MobileTerminalRenderGridReplay/patchBytes()``; the VT - /// synthesizer lives there so this DTO stays a pure value. - public func vtPatchBytes() -> Data { - MobileTerminalRenderGridReplay(self).patchBytes() - } - - private static func normalizedRows(from text: String, maxRows: Int) -> [String] { - var normalized = text - .replacingOccurrences(of: "\r\n", with: "\n") - .replacingOccurrences(of: "\r", with: "\n") - .components(separatedBy: "\n") - if normalized.count > maxRows, normalized.last?.isEmpty == true { - normalized.removeLast() - } - if normalized.count > maxRows { - normalized = Array(normalized.prefix(maxRows)) - } - while normalized.count < maxRows { - normalized.append("") - } - return normalized - } - - private static func trimmingTrailingGridBlanks(_ text: String) -> String { - let scalars = text.unicodeScalars - let space = UnicodeScalar(" ") - let tab = UnicodeScalar("\t") - var end = scalars.endIndex - while end > scalars.startIndex { - let previous = scalars.index(before: end) - guard scalars[previous] == space || scalars[previous] == tab else { break } - end = previous - } - return String(String.UnicodeScalarView(scalars[..<end])) - } - - /// Which terminal screen a full snapshot represents. - public enum Screen: String, Codable, Equatable, Sendable { - /// The normal screen, which owns the scrollback history. - case primary - /// The alternate screen used by full-screen TUIs (entered with `?1049h`). - case alternate - } - - /// One DEC private or ANSI mode to restore on a full snapshot. - public struct ModeSetting: Codable, Equatable, Sendable { - static let decOriginModeCode = 6 - static let decAutowrapModeCode = 7 - static let decAlternateScreenCode = 47 - static let decAlternateScreenSaveCursorCode = 1047 - static let decSaveRestoreCursorCode = 1048 - static let decAlternateScreenSaveRestoreCursorCode = 1049 - - /// The numeric mode code (e.g. `2004` for bracketed paste, `1` for - /// application cursor keys). - public var code: Int - /// `true` for an ANSI mode (`CSI {code} h/l`), `false` for a DEC private - /// mode (`CSI ? {code} h/l`). - public var ansi: Bool - /// Whether the mode is currently set. - public var on: Bool - - public init(code: Int, ansi: Bool = false, on: Bool) { - self.code = code - self.ansi = ansi - self.on = on - } - - /// Whether this DEC private mode is autowrap (`CSI ? 7 h/l`). - public var isDECAutowrapMode: Bool { !ansi && code == Self.decAutowrapModeCode } - - /// Whether this DEC private mode is origin mode (`CSI ? 6 h/l`). - public var isDECOriginMode: Bool { !ansi && code == Self.decOriginModeCode } - - enum CodingKeys: String, CodingKey { - case code - case ansi - case on - } - } - - public struct Cursor: Codable, Equatable, Sendable { - public var row: Int - public var column: Int - public var visible: Bool - public var style: Style - public var blinking: Bool - - public init( - row: Int, - column: Int, - visible: Bool = true, - style: Style = .block, - blinking: Bool = false - ) { - self.row = row - self.column = column - self.visible = visible - self.style = style - self.blinking = blinking - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.row = try container.decode(Int.self, forKey: .row) - self.column = try container.decode(Int.self, forKey: .column) - self.visible = try container.decodeIfPresent(Bool.self, forKey: .visible) ?? true - self.style = try container.decodeIfPresent(Style.self, forKey: .style) ?? .block - self.blinking = try container.decodeIfPresent(Bool.self, forKey: .blinking) ?? false - } - - public enum Style: String, Codable, Equatable, Sendable { - case block - case bar - case underline - case blockHollow = "block_hollow" - } - } - - public struct RowSpan: Codable, Equatable, Sendable { - public var row: Int - public var column: Int - public var styleID: Int - public var text: String - public var cellWidth: Int? - - public init(row: Int, column: Int, styleID: Int = 0, text: String, cellWidth: Int? = nil) { - self.row = row - self.column = column - self.styleID = styleID - self.text = text - self.cellWidth = cellWidth - } - - enum CodingKeys: String, CodingKey { - case row - case column - case styleID = "style_id" - case text - case cellWidth = "cell_width" - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridEmissionState.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridEmissionState.swift deleted file mode 100644 index 7c282eec..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridEmissionState.swift +++ /dev/null @@ -1,53 +0,0 @@ -/// Cached producer state used to choose the next render-grid event payload. -/// -/// A producer stores this compact state instead of the full previous -/// ``MobileTerminalRenderGridFrame`` so the hot render path can diff row -/// signatures without retaining complete viewport snapshots. -public struct MobileTerminalRenderGridEmissionState: Equatable, Sendable { - /// Number of columns in the frame that produced this state. - public let columns: Int - /// Number of rows in the frame that produced this state. - public let rows: Int - /// Terminal byte sequence covered by the frame that produced this state. - public let stateSeq: UInt64 - /// Terminal screen represented by the frame that produced this state. - public let activeScreen: MobileTerminalRenderGridFrame.Screen - /// Resolved terminal theme represented by the source full frame. - public let terminalTheme: TerminalTheme? - /// Raw terminal configuration theme represented by the source full frame. - public let terminalConfigTheme: TerminalTheme? - /// Per-row text/style signatures from ``MobileTerminalRenderGridFrame/rowSignatures()``. - public let rowSignatures: [String] - - /// Creates cached render-grid emission state. - /// - /// - Parameters: - /// - columns: Number of columns in the frame that produced this state. - /// - rows: Number of rows in the frame that produced this state. - /// - stateSeq: Terminal byte sequence covered by the source frame. - /// - activeScreen: Terminal screen represented by the source frame. - /// - terminalTheme: Resolved terminal theme represented by the source frame. - /// - terminalConfigTheme: Raw configuration defaults represented by the source frame. - /// - rowSignatures: Per-row text/style signatures for the source frame. - /// The count must match `rows`. - public init( - columns: Int, - rows: Int, - stateSeq: UInt64, - activeScreen: MobileTerminalRenderGridFrame.Screen, - terminalTheme: TerminalTheme? = nil, - terminalConfigTheme: TerminalTheme? = nil, - rowSignatures: [String] - ) { - precondition(columns >= 0, "columns must be non-negative") - precondition(rows >= 0, "rows must be non-negative") - precondition(rowSignatures.count == rows, "rowSignatures count must match rows") - self.columns = columns - self.rows = rows - self.stateSeq = stateSeq - self.activeScreen = activeScreen - self.terminalTheme = terminalTheme - self.terminalConfigTheme = terminalConfigTheme - self.rowSignatures = rowSignatures - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift deleted file mode 100644 index 24463849..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift +++ /dev/null @@ -1,303 +0,0 @@ -import Foundation - -extension String { - func clippedToRenderGridColumns(_ columns: Int) -> String { - var occupiedColumns = 0 - var clipped = "" - for character in self { - let width = character.renderGridEstimatedCellWidth - guard occupiedColumns + width <= columns else { break } - clipped.append(character) - occupiedColumns += width - } - return clipped - } - - var renderGridEstimatedCellWidth: Int { - reduce(0) { width, character in - width + character.renderGridEstimatedCellWidth - } - } -} - -extension MobileTerminalRenderGridFrame.RowSpan { - var hasWidthSensitiveScalars: Bool { - text.unicodeScalars.contains { $0.isRenderGridWidthSensitiveScalar } - } - - var gridCellWidth: Int { - cellWidth ?? max(1, text.renderGridEstimatedCellWidth) - } -} - -extension Character { - var renderGridEstimatedCellWidth: Int { - let scalars = unicodeScalars - guard scalars.contains(where: { !$0.isRenderGridZeroWidthScalar }) else { - return 0 - } - if scalars.contains(where: { $0.isRenderGridWideScalar }) - || scalars.contains(where: { $0.isRenderGridEmojiPresentationScalar }) { - return 2 - } - return 1 - } - - var canExpandForAmbiguousRenderGridWidth: Bool { - unicodeScalars.contains { $0.isRenderGridAmbiguousWidthScalar } - } -} - -extension UnicodeScalar { - fileprivate var isRenderGridWidthSensitiveScalar: Bool { - isRenderGridZeroWidthScalar - || isRenderGridWideScalar - || isRenderGridEmojiPresentationScalar - || isRenderGridAmbiguousWidthScalar - } - - var isRenderGridZeroWidthScalar: Bool { - switch value { - case 0x0300...0x036F, - 0x061C, - 0x1AB0...0x1AFF, - 0x1DC0...0x1DFF, - 0x180B...0x180F, - 0x200B...0x200F, - 0x20D0...0x20FF, - 0x202A...0x202E, - 0x2060...0x206F, - 0xFE00...0xFE0F, - 0xFEFF, - 0xFE20...0xFE2F, - 0xE0100...0xE01EF: - return true - default: - return false - } - } - - fileprivate var isRenderGridWideScalar: Bool { - switch value { - case 0x1100...0x115F, - 0x231A...0x231B, - 0x2329...0x232A, - 0x23E9...0x23EC, - 0x23F0, - 0x23F3, - 0x25FD...0x25FE, - 0x2614...0x2615, - 0x2648...0x2653, - 0x267F, - 0x2693, - 0x26A1, - 0x26AA...0x26AB, - 0x26BD...0x26BE, - 0x26C4...0x26C5, - 0x26CE, - 0x26D4, - 0x26EA, - 0x26F2...0x26F3, - 0x26F5, - 0x26FA, - 0x26FD, - 0x2705, - 0x270A...0x270B, - 0x2728, - 0x274C, - 0x274E, - 0x2753...0x2755, - 0x2757, - 0x2795...0x2797, - 0x27B0, - 0x27BF, - 0x2B1B...0x2B1C, - 0x2B50, - 0x2B55, - 0x2E80...0xA4CF, - 0xAC00...0xD7A3, - 0xF900...0xFAFF, - 0xFE10...0xFE19, - 0xFE30...0xFE6F, - 0xFF00...0xFF60, - 0xFFE0...0xFFE6, - 0x16FE0...0x16FE4, - 0x16FF0...0x16FF6, - 0x17000...0x187FF, - 0x18800...0x18AFF, - 0x18B00...0x18CD5, - 0x18CFF, - 0x18D00...0x18D1E, - 0x18D80...0x18DF2, - 0x1AFF0...0x1AFF3, - 0x1AFF5...0x1AFFB, - 0x1AFFD...0x1AFFE, - 0x1B000...0x1B122, - 0x1B132, - 0x1B150...0x1B152, - 0x1B155, - 0x1B164...0x1B167, - 0x1B170...0x1B2FB, - 0x1D300...0x1D356, - 0x1D360...0x1D376, - 0x1F004, - 0x1F0CF, - 0x1F18E, - 0x1F191...0x1F19A, - 0x1F200...0x1F202, - 0x1F210...0x1F23B, - 0x1F240...0x1F248, - 0x1F250...0x1F251, - 0x1F260...0x1F265, - 0x1F300...0x1F320, - 0x1F32D...0x1F335, - 0x1F337...0x1F37C, - 0x1F37E...0x1F393, - 0x1F3A0...0x1F3CA, - 0x1F3CF...0x1F3D3, - 0x1F3E0...0x1F3F0, - 0x1F3F4, - 0x1F3F8...0x1F43E, - 0x1F440, - 0x1F442...0x1F4FC, - 0x1F4FF...0x1F53D, - 0x1F54B...0x1F54E, - 0x1F550...0x1F567, - 0x1F57A, - 0x1F595...0x1F596, - 0x1F5A4, - 0x1F5FB...0x1F64F, - 0x1F680...0x1F6C5, - 0x1F6CC, - 0x1F6D0...0x1F6D2, - 0x1F6D5...0x1F6D8, - 0x1F6DC...0x1F6DF, - 0x1F6EB...0x1F6EC, - 0x1F6F4...0x1F6FC, - 0x1F7E0...0x1F7EB, - 0x1F7F0, - 0x1F90C...0x1F93A, - 0x1F93C...0x1F945, - 0x1F947...0x1F9FF, - 0x1FA70...0x1FA7C, - 0x1FA80...0x1FA8A, - 0x1FA8E...0x1FAC6, - 0x1FAC8, - 0x1FACD...0x1FADC, - 0x1FADF...0x1FAEA, - 0x1FAEF...0x1FAF8, - 0x20000...0x3FFFD: - return true - default: - return false - } - } - - fileprivate var isRenderGridEmojiPresentationScalar: Bool { - switch value { - case 0xFE0F: - return true - default: - return false - } - } - - fileprivate var isRenderGridAmbiguousWidthScalar: Bool { - switch value { - case 0x00A1, - 0x00A4, - 0x00A7...0x00A8, - 0x00AA, - 0x00AD...0x00AE, - 0x00B0...0x00B4, - 0x00B6...0x00BA, - 0x00BC...0x00BF, - 0x00C6, - 0x00D0, - 0x00D7...0x00D8, - 0x00DE...0x00E1, - 0x00E6, - 0x00E8...0x00EA, - 0x00EC...0x00ED, - 0x00F0, - 0x00F2...0x00F3, - 0x00F7...0x00FA, - 0x00FC, - 0x00FE, - 0x0101, - 0x0111, - 0x0113, - 0x011B, - 0x0126...0x0127, - 0x012B, - 0x0131...0x0133, - 0x0138, - 0x013F...0x0142, - 0x0144, - 0x0148...0x014B, - 0x014D, - 0x0152...0x0153, - 0x0166...0x0167, - 0x016B, - 0x01CE, - 0x01D0, - 0x01D2, - 0x01D4, - 0x01D6, - 0x01D8, - 0x01DA, - 0x01DC, - 0x0251, - 0x0261, - 0x02C4, - 0x02C7, - 0x02C9...0x02CB, - 0x02CD, - 0x02D0, - 0x02D8...0x02DB, - 0x02DD, - 0x02DF, - 0x0391...0x03A1, - 0x03A3...0x03A9, - 0x03B1...0x03C1, - 0x03C3...0x03C9, - 0x0401, - 0x0410...0x044F, - 0x0451, - 0x2010...0x2027, - 0x2030...0x205E, - 0x2074, - 0x207F, - 0x2081...0x2084, - 0x20AC, - 0x2103, - 0x2105, - 0x2109, - 0x2113, - 0x2116, - 0x2121...0x2122, - 0x2126, - 0x212B, - 0x2153...0x2154, - 0x215B...0x215E, - 0x2160...0x216B, - 0x2170...0x2179, - 0x2189, - 0x2190...0x21FF, - 0x2200...0x22FF, - 0x2300...0x2319, - 0x232C...0x23FF, - 0x2460...0x24E9, - 0x2500...0x259F, - 0x25A0...0x25FF, - 0x2600...0x27BF, - 0x2800...0x28FF, - 0x2B00...0x2BFF, - 0xE000...0xF8FF, - 0xFFFD: - return true - default: - return false - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Coding.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Coding.swift deleted file mode 100644 index 7609b1a8..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Coding.swift +++ /dev/null @@ -1,24 +0,0 @@ -extension MobileTerminalRenderGridFrame { - enum CodingKeys: String, CodingKey { - case format - case surfaceID = "surface_id" - case stateSeq = "state_seq" - case columns - case rows - case cursor - case full - case clearedRows = "cleared_rows" - case styles - case rowSpans = "row_spans" - case activeScreen = "active_screen" - case modes - case terminalForeground = "terminal_foreground" - case terminalBackground = "terminal_background" - case terminalCursorColor = "terminal_cursor_color" - case terminalTheme = "terminal_theme" - case terminalConfigTheme = "terminal_config_theme" - case terminalThemeRevision = "terminal_theme_revision" - case scrollbackRows = "scrollback_rows" - case scrollbackSpans = "scrollback_spans" - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Emission.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Emission.swift deleted file mode 100644 index 0c9037b1..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Emission.swift +++ /dev/null @@ -1,79 +0,0 @@ -extension MobileTerminalRenderGridFrame { - /// Cached producer state for this frame. - /// - /// Producers keep this compact value after emitting a frame, then pass it to - /// ``renderGridEmission(comparedTo:)`` for the next full producer snapshot. - public var emissionState: MobileTerminalRenderGridEmissionState { - MobileTerminalRenderGridEmissionState( - columns: columns, - rows: rows, - stateSeq: stateSeq, - activeScreen: activeScreen, - terminalTheme: terminalTheme, - terminalConfigTheme: terminalConfigTheme, - rowSignatures: rowSignatures() - ) - } - - /// Selects the event frame to emit compared with a previous producer state. - /// - /// The returned frame is `self` for first frames, shape changes, and changed - /// frames that must stay full because DEC origin mode is active. Otherwise it - /// is a row delta, or `nil` when the producer snapshot is unchanged. - /// - /// - Parameter previous: The compact state from the last emitted snapshot, or - /// `nil` when no prior frame was emitted for the surface. - /// - Returns: The frame to emit plus the compact state to cache for the next - /// comparison, or `nil` when no event should be emitted. - /// - Throws: ``MobileTerminalRenderGridError`` if a generated delta would be invalid. - public func renderGridEmission( - comparedTo previous: MobileTerminalRenderGridEmissionState? - ) throws -> (frame: MobileTerminalRenderGridFrame, state: MobileTerminalRenderGridEmissionState)? { - let nextSignatures = rowSignatures() - let nextState = MobileTerminalRenderGridEmissionState( - columns: columns, - rows: rows, - stateSeq: stateSeq, - activeScreen: activeScreen, - terminalTheme: terminalTheme, - terminalConfigTheme: terminalConfigTheme, - rowSignatures: nextSignatures - ) - guard let previous, - previous.columns == columns, - previous.rows == rows else { - return (self, nextState) - } - if previous.activeScreen != activeScreen { - return (self, nextState) - } - if previous.terminalTheme != terminalTheme { - return (self, nextState) - } - if previous.terminalConfigTheme != terminalConfigTheme { - return (self, nextState) - } - - var changedRows = Set<Int>() - let count = min(previous.rowSignatures.count, nextSignatures.count) - for index in 0..<count where previous.rowSignatures[index] != nextSignatures[index] { - changedRows.insert(index) - } - - if changedRows.isEmpty, previous.stateSeq == stateSeq { - return nil - } - - // Row repaints under DEC origin mode stay full snapshots, but a - // cursor-only advance (no changed rows) does not need one: the delta - // replay disables origin mode before its absolute cursor move, and a - // full-screen app holding DECOM would otherwise promote every - // keystroke tick into a full-grid payload. - if !changedRows.isEmpty, modes.contains(where: { $0.isDECOriginMode && $0.on }) { - return (self, nextState) - } - - let deltaFrame = try filteredRows(changedRows, full: false) - return (deltaFrame, nextState) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Style.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Style.swift deleted file mode 100644 index b8c047e5..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+Style.swift +++ /dev/null @@ -1,117 +0,0 @@ -extension MobileTerminalRenderGridFrame { - /// Visual attributes and color metadata shared by render-grid cells. - public struct Style: Codable, Equatable, Sendable { - /// The unstyled render-grid cell style. - public static let `default` = Style(id: 0) - - /// Stable style identifier referenced by row spans. - public var id: Int - /// Resolved foreground encoded as a hexadecimal RGB string. - public var foreground: String? - /// Resolved background encoded as a hexadecimal RGB string. - public var background: String? - /// Semantic source for ``foreground``; `nil` denotes a legacy RGB-only frame. - public var foregroundSource: ColorSource? - /// Palette index when ``foregroundSource`` is ``ColorSource/palette``. - public var foregroundPaletteIndex: Int? - /// Semantic source for ``background``; `nil` denotes a legacy RGB-only frame. - public var backgroundSource: ColorSource? - /// Palette index when ``backgroundSource`` is ``ColorSource/palette``. - public var backgroundPaletteIndex: Int? - /// Whether bold intensity is enabled. - public var bold: Bool - /// Whether faint intensity is enabled. - public var faint: Bool - /// Whether italic styling is enabled. - public var italic: Bool - /// Whether underline styling is enabled. - public var underline: Bool - /// Whether blinking is enabled. - public var blink: Bool - /// Whether foreground and background are inverted. - public var inverse: Bool - /// Whether glyphs are hidden. - public var invisible: Bool - /// Whether strikethrough styling is enabled. - public var strikethrough: Bool - /// Whether overline styling is enabled. - public var overline: Bool - - /// Creates a render-grid cell style. - public init( - id: Int, - foreground: String? = nil, - background: String? = nil, - foregroundSource: ColorSource? = nil, - foregroundPaletteIndex: Int? = nil, - backgroundSource: ColorSource? = nil, - backgroundPaletteIndex: Int? = nil, - bold: Bool = false, - faint: Bool = false, - italic: Bool = false, - underline: Bool = false, - blink: Bool = false, - inverse: Bool = false, - invisible: Bool = false, - strikethrough: Bool = false, - overline: Bool = false - ) { - self.id = id - self.foreground = foreground - self.background = background - self.foregroundSource = foregroundSource - self.foregroundPaletteIndex = foregroundPaletteIndex - self.backgroundSource = backgroundSource - self.backgroundPaletteIndex = backgroundPaletteIndex - self.bold = bold - self.faint = faint - self.italic = italic - self.underline = underline - self.blink = blink - self.inverse = inverse - self.invisible = invisible - self.strikethrough = strikethrough - self.overline = overline - } - - /// Decodes a style while preserving compatibility with legacy frames. - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.id = try container.decode(Int.self, forKey: .id) - self.foreground = try container.decodeIfPresent(String.self, forKey: .foreground) - self.background = try container.decodeIfPresent(String.self, forKey: .background) - self.foregroundSource = try container.decodeIfPresent(ColorSource.self, forKey: .foregroundSource) - self.foregroundPaletteIndex = try container.decodeIfPresent(Int.self, forKey: .foregroundPaletteIndex) - self.backgroundSource = try container.decodeIfPresent(ColorSource.self, forKey: .backgroundSource) - self.backgroundPaletteIndex = try container.decodeIfPresent(Int.self, forKey: .backgroundPaletteIndex) - self.bold = try container.decodeIfPresent(Bool.self, forKey: .bold) ?? false - self.faint = try container.decodeIfPresent(Bool.self, forKey: .faint) ?? false - self.italic = try container.decodeIfPresent(Bool.self, forKey: .italic) ?? false - self.underline = try container.decodeIfPresent(Bool.self, forKey: .underline) ?? false - self.blink = try container.decodeIfPresent(Bool.self, forKey: .blink) ?? false - self.inverse = try container.decodeIfPresent(Bool.self, forKey: .inverse) ?? false - self.invisible = try container.decodeIfPresent(Bool.self, forKey: .invisible) ?? false - self.strikethrough = try container.decodeIfPresent(Bool.self, forKey: .strikethrough) ?? false - self.overline = try container.decodeIfPresent(Bool.self, forKey: .overline) ?? false - } - - enum CodingKeys: String, CodingKey { - case id - case foreground - case background - case foregroundSource = "foreground_source" - case foregroundPaletteIndex = "foreground_palette_index" - case backgroundSource = "background_source" - case backgroundPaletteIndex = "background_palette_index" - case bold - case faint - case italic - case underline - case blink - case inverse - case invisible - case strikethrough - case overline - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+ModeReset.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+ModeReset.swift deleted file mode 100644 index 8f6c59e9..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+ModeReset.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation - -extension MobileTerminalRenderGridReplay { - func appendStructuralScreenReset(to bytes: inout Data) { - bytes.append(Data("\u{1B}[?47l\u{1B}[?1047l\u{1B}[?1049l".utf8)) - } - - func appendDefaultModeBaseline(to bytes: inout Data) { - // ?3l (DECCOLM) must follow ?40l: with mode 40 off Ghostty's deccolm - // clears the stored ?3 value and returns without resizing, which is - // the only safe way to reset the mode without fighting the remote - // grid's viewport policy. - // Built with `+=` statements, not one `+` chain: the chained literal - // expression is borderline for the Release type checker and failed CI - // with "unable to type-check this expression in reasonable time" on - // slower runners. - var baseline = "\u{1B}[2l\u{1B}[4l\u{1B}[12h\u{1B}[20l" - baseline += "\u{1B}[?1l\u{1B}[?4l\u{1B}[?5l\u{1B}[?6l\u{1B}[?7h\u{1B}[?8l\u{1B}[?9l" - baseline += "\u{1B}[?40l\u{1B}[?3l\u{1B}[?45l\u{1B}[?66l\u{1B}>\u{1B}[?67l\u{1B}[?69l" - baseline += "\u{1B}[?1000l\u{1B}[?1002l\u{1B}[?1003l\u{1B}[?1004l" - baseline += "\u{1B}[?1005l\u{1B}[?1006l\u{1B}[?1007h\u{1B}[?1015l\u{1B}[?1016l" - baseline += "\u{1B}[?1035h\u{1B}[?1036h\u{1B}[?1039l\u{1B}[?1045l\u{1B}[?2004l" - baseline += "\u{1B}[?2027l\u{1B}[?2031l\u{1B}[?2048l" - bytes.append(Data(baseline.utf8)) - } - - func appendSavedModeBankReset(to bytes: inout Data) { - // XTSAVE (CSI ? Pm s) overwrites Ghostty's saved-mode slots with the - // current values, which are all defaults right after the structural - // reset and default baseline. RIS cleared the saved bank outright; - // without this, a mode XTSAVE'd by a previous program on the reused - // surface would survive the replay and a later XTRESTORE (CSI ? Pm r) - // could resurrect it. The cursor modes ?12/?25/?1048 are forced to their - // Ghostty defaults first so their saved slots are deterministic; the - // paint sequence and the final cursor restore adjust the live values - // afterwards without touching the bank. Ghostty caps CSI parameters - // at 24 per sequence, so the bank is overwritten in two batches. - // 2026 is deliberately absent: it is held on for the synchronized - // replay and must not be saved in that state. - bytes.append(Data("\u{1B}[?12l\u{1B}[?25h\u{1B}[?1048l".utf8)) - bytes.append(Data("\u{1B}[?1;3;4;5;6;7;8;9;12;25;40;45;47;66;67;69;1000;1002;1003s".utf8)) - bytes.append(Data( - "\u{1B}[?1004;1005;1006;1007;1015;1016;1035;1036;1039;1045;1047;1048;1049;2004;2027;2031;2048s".utf8 - )) - } - - func appendPrePaintModeRestores(to bytes: inout Data) { - for mode in frame.modes where !mode.ansi && mode.code == 2027 { - bytes.append(Data("\u{1B}[?2027\(mode.on ? "h" : "l")".utf8)) - } - } - - func isReplayExcludedMode(_ mode: MobileTerminalRenderGridFrame.ModeSetting) -> Bool { - guard !mode.ansi else { return false } - switch mode.code { - // DECCOLM (?3) is geometry, not paint state: Ghostty implements reset - // as a resize to 80 columns, while mobile render-grid delivery applies - // the authoritative remote grid through its viewport policy. - case 3, 12, 25, 47, 1047, 1048, 1049, 2026, 2031, 2048: - return true - default: - return false - } - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+Palette.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+Palette.swift deleted file mode 100644 index da27db81..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+Palette.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation - -extension MobileTerminalRenderGridReplay { - func appendPaletteRestore(to bytes: inout Data) { - guard let effectivePalette = frame.terminalTheme?.palette else { return } - guard effectivePalette.count == TerminalTheme.paletteCount - || effectivePalette.count == TerminalTheme.extendedPaletteCount else { return } - bytes.reserveCapacity(bytes.count + effectivePalette.count * 28) - appendPaletteReset(count: effectivePalette.count, to: &bytes) - let configPalette = frame.terminalConfigTheme?.palette - for (index, color) in effectivePalette.enumerated() { - guard let rgb = TerminalTheme.rgbComponents(color) else { continue } - if let configPalette, - configPalette.indices.contains(index), - let configRGB = TerminalTheme.rgbComponents(configPalette[index]), - configRGB.red == rgb.red, - configRGB.green == rgb.green, - configRGB.blue == rgb.blue { continue } - appendPaletteOverride(index: index, rgb: rgb, to: &bytes) - } - } - - private func appendPaletteReset(count: Int, to bytes: inout Data) { - bytes.append(contentsOf: [0x1B, 0x5D, 0x31, 0x30, 0x34]) - if count < TerminalTheme.extendedPaletteCount { - for index in 0..<count { - bytes.append(0x3B) - appendDecimal(index, to: &bytes) - } - } - bytes.append(contentsOf: [0x1B, 0x5C]) - } - - private func appendPaletteOverride( - index: Int, - rgb: (red: Int, green: Int, blue: Int), - to bytes: inout Data - ) { - bytes.append(contentsOf: [0x1B, 0x5D, 0x34, 0x3B]) - appendDecimal(index, to: &bytes) - bytes.append(contentsOf: [0x3B, 0x72, 0x67, 0x62, 0x3A]) - appendHexByte(rgb.red, to: &bytes) - bytes.append(0x2F) - appendHexByte(rgb.green, to: &bytes) - bytes.append(0x2F) - appendHexByte(rgb.blue, to: &bytes) - bytes.append(contentsOf: [0x1B, 0x5C]) - } - - private func appendDecimal(_ value: Int, to bytes: inout Data) { - if value >= 100 { bytes.append(UInt8(value / 100) + 0x30) } - if value >= 10 { bytes.append(UInt8((value / 10) % 10) + 0x30) } - bytes.append(UInt8(value % 10) + 0x30) - } - - private func appendHexByte(_ value: Int, to bytes: inout Data) { - bytes.append(hexDigit((value >> 4) & 0x0F)) - bytes.append(hexDigit(value & 0x0F)) - } - - private func hexDigit(_ value: Int) -> UInt8 { - value < 10 ? UInt8(value) + 0x30 : UInt8(value - 10) + 0x61 - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+Style.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+Style.swift deleted file mode 100644 index 09512832..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay+Style.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -extension MobileTerminalRenderGridReplay { - func sgrBytes(for style: MobileTerminalRenderGridFrame.Style) -> Data { - var codes = ["0"] - if style.bold { codes.append("1") } - if style.faint { codes.append("2") } - if style.italic { codes.append("3") } - if style.underline { codes.append("4") } - if style.blink { codes.append("5") } - if style.inverse { codes.append("7") } - if style.invisible { codes.append("8") } - if style.strikethrough { codes.append("9") } - if style.overline { codes.append("53") } - if style.foregroundSource == .defaultColor { - codes.append("39") - } else if style.foregroundSource == .palette, - let index = style.foregroundPaletteIndex, - (0...255).contains(index) { - codes.append("38;5;\(index)") - } else if let foreground = rgbComponents(style.foreground) { - codes.append("38;2;\(foreground.red);\(foreground.green);\(foreground.blue)") - } - if style.backgroundSource == .defaultColor { - codes.append("49") - } else if style.backgroundSource == .palette, - let index = style.backgroundPaletteIndex, - (0...255).contains(index) { - codes.append("48;5;\(index)") - } else if let background = rgbComponents(style.background) { - codes.append("48;2;\(background.red);\(background.green);\(background.blue)") - } - return Data("\u{1B}[\(codes.joined(separator: ";"))m".utf8) - } - - func rgbComponents(_ value: String?) -> (red: Int, green: Int, blue: Int)? { - guard var value else { return nil } - if value.hasPrefix("#") { - value.removeFirst() - } - guard value.count == 6, let raw = Int(value, radix: 16) else { return nil } - return ((raw >> 16) & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF) - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift deleted file mode 100644 index b0419712..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift +++ /dev/null @@ -1,530 +0,0 @@ -import Foundation - -/// Synthesizes a VT byte stream that reproduces a ``MobileTerminalRenderGridFrame`` -/// when fed to a terminal emulator. -/// -/// The replay is a pure, stateless transform: it reads the frame's value -/// properties and emits the escape-sequence bytes that paint it. Splitting the -/// synthesizer out of ``MobileTerminalRenderGridFrame`` keeps the wire DTO a -/// pure value with no rendering policy, while ``MobileTerminalRenderGridFrame`` -/// retains thin ``MobileTerminalRenderGridFrame/vtPatchBytes()`` / -/// ``MobileTerminalRenderGridFrame/vtReplacementBytes()`` accessors that -/// forward here for call-site compatibility. -public struct MobileTerminalRenderGridReplay: Sendable { - /// The frame this replay renders into VT bytes. - public let frame: MobileTerminalRenderGridFrame - - /// Creates a replay over `frame`. - /// - /// - Parameter frame: The render-grid frame to synthesize bytes for. - public init(_ frame: MobileTerminalRenderGridFrame) { - self.frame = frame - } - - /// Synthesize a VT byte stream that reproduces ``frame`` when fed to a - /// terminal emulator. - /// - /// A **full** frame is a faithful cold-attach snapshot: it resets the - /// terminal, restores dynamic default colors, repaints scrollback and the - /// visible viewport as a natural scrolling flow, restores the active screen - /// (`?1049h` for the alternate screen), reapplies non-default DEC/ANSI - /// modes, and finally restores the cursor. A **delta** frame normalizes - /// coordinate-affecting modes, then clears and repaints only the changed - /// viewport rows using absolute producer row indexes. - /// - /// - Returns: The synthesized escape-sequence bytes. - public func patchBytes() -> Data { - frame.full ? fullSnapshotBytes() : deltaPatchBytes() - } - - /// Alias for ``patchBytes()``; the byte stream both replaces a full screen - /// and patches a delta depending on ``MobileTerminalRenderGridFrame/full``. - /// - /// - Returns: The synthesized escape-sequence bytes. - public func replacementBytes() -> Data { - patchBytes() - } - - /// Synthesizes only the frame's effective terminal color state. - /// - /// This patch updates foreground, background, cursor, and palette colors - /// without clearing cells, moving the cursor, or replacing terminal text. - /// Hybrid mobile mirrors use it when raw PTY bytes own content but a newer - /// render-grid theme revision must still repaint the mounted surface. - /// - Returns: VT color commands for the frame's theme. - public func themePatchBytes() -> Data { - let theme = frame.terminalTheme - var bytes = Data() - bytes.append(oscColorOrResetBytes(10, reset: 110, frame.terminalForeground ?? theme?.foreground)) - bytes.append(oscColorOrResetBytes(11, reset: 111, frame.terminalBackground ?? theme?.background)) - bytes.append(oscColorOrResetBytes(12, reset: 112, frame.terminalCursorColor)) - appendPaletteRestore(to: &bytes) - return bytes - } - - private func deltaPatchBytes() -> Data { - var bytes = Data() - let stylesByID = styleMapByID(frame.styles) - let defaultStyle = stylesByID[0] ?? .default - let autowrapMode = deltaReplayAutowrapMode() - if frame.cursor == nil { bytes.append(Data("\u{1B}[s".utf8)) } - bytes.append(deltaReplayModeNormalizationBytes()) - let rowsToClear = Set(frame.clearedRows).union(frame.rowSpans.map(\.row)).sorted() - for row in rowsToClear { - bytes.append(sgrBytes(for: defaultStyle)) - bytes.append(Data("\u{1B}[\(row + 1);1H\u{1B}[2K".utf8)) - } - var activeStyleID: Int? - for span in frame.rowSpans { - guard !span.text.isEmpty else { continue } - let style = activeStyleID != span.styleID ? stylesByID[span.styleID] : nil - appendSpanReplay(span, row: span.row, style: style, to: &bytes) - if activeStyleID != span.styleID, - style != nil { - activeStyleID = span.styleID - } - } - bytes.append(sgrBytes(for: defaultStyle)) - // Current producers list autowrap in every delta frame, so a missing - // entry is a legacy-producer delta. Defaulting the restore to on is - // safe there: replay is the surface's only writer and each patch - // re-normalizes modes before painting. - bytes.append(modeBytes(autowrapMode ?? .init(code: MobileTerminalRenderGridFrame.ModeSetting.decAutowrapModeCode, ansi: false, on: true))) - if frame.cursor == nil { bytes.append(Data("\u{1B}[u".utf8)) } - // A delta never hides the cursor while painting, so (unlike a full - // snapshot) it leaves a nil cursor untouched instead of forcing it - // visible. - if let cursor = frame.cursor { - bytes.append(cursorStyleBytes(for: cursor)) - if cursor.visible { - bytes.append(Data("\u{1B}[?25h\u{1B}[\(cursor.row + 1);\(cursor.column + 1)H".utf8)) - } else { - bytes.append(Data("\u{1B}[?25l\u{1B}[\(cursor.row + 1);\(cursor.column + 1)H".utf8)) - } - } - return bytes - } - - private func fullSnapshotBytes() -> Data { - var bytes = Data() - let stylesByID = styleMapByID(frame.styles) - let defaultStyle = stylesByID[0] ?? .default - // Leads with DECSCUSR 0: cursor shape is per-screen state in Ghostty - // and survives the alternate-screen roundtrip, so without this a stale - // bar/underline shape from the reused surface's primary screen would - // resurface when a replayed TUI later exits the alternate screen. RIS - // used to clear it; the frame's captured cursor style is reapplied on - // the active screen at the end of the restore. - let screenStateReset = "\u{1B}[0 q\u{1B}[1\"q\u{1B}[0\"q\u{1B}[999<u\u{1B}[0;1=u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B" - let hyperlinkStateReset = "\u{1B}]8;;\u{1B}\\" - // OSC 133;D returns the cursor's semantic content to `.output`, the - // fresh-screen default. RIS used to clear this; without it a reused - // surface still inside an OSC 133 prompt/input region would stamp that - // stale semantic state onto every replayed cell. Per-screen state, so - // emit it alongside each hyperlink reset (once per screen). - let semanticPromptReset = "\u{1B}]133;D\u{1B}\\" - - // Apply the whole restore inside a synchronized update so the client - // never presents the empty reset/clear frame before the snapshot lands. - // Avoid `ESC c`: RIS clears before synchronized output can be enabled. - // These are Ghostty-supported resets for state the replay depends on: - // main display, protected cells, key/input flags, OSC 8 hyperlinks, - // charset mapping, scroll margins, tabs, both screens, cursor position, - // viewport contents, and scrollback. - bytes.append(Data("\u{1B}[?2026h\u{1B}[0$}\u{1B}[>m\u{1B}[r\u{1B}[?69l\u{1B}[?5W".utf8)) - appendStructuralScreenReset(to: &bytes) - bytes.append(Data(hyperlinkStateReset.utf8)) - bytes.append(Data(semanticPromptReset.utf8)) - bytes.append(Data(screenStateReset.utf8)) - appendDefaultModeBaseline(to: &bytes) - appendSavedModeBankReset(to: &bytes) - appendPrePaintModeRestores(to: &bytes) - - // Dynamic default colors (OSC 10/11/12). Nil frame values reset the - // previous override so a full snapshot behaves like the old RIS path. - // Apply them before clearing so blank cells use the captured defaults. - bytes.append(oscColorOrResetBytes(10, reset: 110, frame.terminalForeground)) - bytes.append(oscColorOrResetBytes(11, reset: 111, frame.terminalBackground)) - bytes.append(oscColorOrResetBytes(12, reset: 112, frame.terminalCursorColor)) - appendPaletteRestore(to: &bytes) - bytes.append(sgrBytes(for: defaultStyle)) - // DECSC at home with the default pen resets each screen's saved - // cursor to the RIS baseline; a stale DECSC from the reused surface - // must not survive the replay, and the snapshot cursor is never - // saved (a later bare DECRC/?1048l restore should land on the - // default, matching what RIS left behind). - bytes.append(Data("\u{1B}[H\u{1B}7\u{1B}[2J\u{1B}[3J\u{1B}[?1049h".utf8)) - - bytes.append(Data(hyperlinkStateReset.utf8)) - bytes.append(Data(semanticPromptReset.utf8)) - bytes.append(Data(screenStateReset.utf8)) - bytes.append(sgrBytes(for: defaultStyle)) - bytes.append(Data("\u{1B}[H\u{1B}7\u{1B}[2J\u{1B}[?1049l\u{1B}[H".utf8)) - - // Paint with autowrap and the cursor off so a full-width row plus an - // explicit newline cannot wrap into a phantom blank line, and so the - // restore does not flicker the cursor across the grid. - bytes.append(Data("\u{1B}[?7l\u{1B}[?25l".utf8)) - bytes.append(sgrBytes(for: defaultStyle)) - - if frame.activeScreen == .alternate { - // Scrollback belongs to the primary screen; flow it there first so - // it is preserved behind the alternate screen, then enter the - // alternate screen and paint the TUI viewport. - appendFlowLines( - &bytes, - spans: frame.scrollbackSpans, - lineCount: frame.scrollbackRows, - stylesByID: stylesByID, - defaultStyle: defaultStyle, - terminateLast: true - ) - bytes.append(Data("\u{1B}[?1049h".utf8)) - bytes.append(Data(screenStateReset.utf8)) - bytes.append(sgrBytes(for: defaultStyle)) - appendFlowLines( - &bytes, - spans: frame.rowSpans, - lineCount: frame.rows, - stylesByID: stylesByID, - defaultStyle: defaultStyle, - terminateLast: false - ) - } else { - // Primary: scrollback then the viewport as one continuous flow so - // the scrollback naturally lands in the client's history. - let offsetViewportSpans = frame.rowSpans.map { span in - MobileTerminalRenderGridFrame.RowSpan( - row: span.row + frame.scrollbackRows, - column: span.column, - styleID: span.styleID, - text: span.text, - cellWidth: span.cellWidth - ) - } - appendFlowLines( - &bytes, - spans: frame.scrollbackSpans + offsetViewportSpans, - lineCount: frame.scrollbackRows + frame.rows, - stylesByID: stylesByID, - defaultStyle: defaultStyle, - terminateLast: false - ) - } - - // Reapply modes last so autowrap returns to its captured value - // (undoing the temporary `?7l`) and mouse/paste/app-key modes are live. - // The baseline also covers older frames that omitted `modes`, so stale - // state from a reused surface cannot leak through the full replay. - appendDefaultModeBaseline(to: &bytes) - for mode in frame.modes where !isReplayExcludedMode(mode) { - bytes.append(modeBytes(mode)) - } - - appendCursorRestore(&bytes) - bytes.append(Data("\u{1B}[?2026l".utf8)) - return bytes - } - - private func deltaReplayModeNormalizationBytes() -> Data { - // Disable origin mode so CUP row indexes target absolute viewport rows, - // and disable autowrap while painting so full-width spans cannot scroll - // a preserved scroll region. - Data(( - "\u{1B}[?\(MobileTerminalRenderGridFrame.ModeSetting.decOriginModeCode)l" + - "\u{1B}[?\(MobileTerminalRenderGridFrame.ModeSetting.decAutowrapModeCode)l" - ).utf8) - } - - private func deltaReplayAutowrapMode() -> MobileTerminalRenderGridFrame.ModeSetting? { - frame.modes.first(where: \.isDECAutowrapMode) - } - - /// Append `lineCount` lines (rows `0..<lineCount` of `spans`) as a natural - /// scrolling flow: each line resets to the default style, positions its - /// spans with `CHA`, and is separated from the next by CRLF. - private func appendFlowLines( - _ bytes: inout Data, - spans: [MobileTerminalRenderGridFrame.RowSpan], - lineCount: Int, - stylesByID: [Int: MobileTerminalRenderGridFrame.Style], - defaultStyle: MobileTerminalRenderGridFrame.Style, - terminateLast: Bool - ) { - guard lineCount > 0 else { return } - var spansByRow: [Int: [MobileTerminalRenderGridFrame.RowSpan]] = [:] - for span in spans { - spansByRow[span.row, default: []].append(span) - } - for line in 0..<lineCount { - if line > 0 { - bytes.append(Data("\r\n".utf8)) - } - bytes.append(sgrBytes(for: defaultStyle)) - var activeStyleID = 0 - for span in (spansByRow[line] ?? []).sorted(by: { $0.column < $1.column }) { - guard !span.text.isEmpty else { continue } - let style = activeStyleID != span.styleID ? stylesByID[span.styleID] : nil - appendSpanReplay(span, row: nil, style: style, to: &bytes) - if activeStyleID != span.styleID, - style != nil { - activeStyleID = span.styleID - } - } - } - if terminateLast { - bytes.append(Data("\r\n".utf8)) - } - } - - private func appendSpanReplay( - _ span: MobileTerminalRenderGridFrame.RowSpan, - row: Int?, - style: MobileTerminalRenderGridFrame.Style?, - to bytes: inout Data - ) { - guard shouldPinColumns(for: span) else { - appendCursor(row: row, column: span.column, to: &bytes) - if let style { - bytes.append(sgrBytes(for: style)) - } - appendVTPrintable(span.text, to: &bytes) - return - } - - guard let widths = sourceCellWidths(for: span.text, targetWidth: span.gridCellWidth) else { - appendCursor(row: row, column: span.column, to: &bytes) - if let style { - bytes.append(sgrBytes(for: style)) - } - appendVTPrintable(span.text, to: &bytes) - return - } - - var column = span.column - var needsStyle = true - for (character, width) in zip(span.text, widths) { - appendCursor(row: row, column: column, to: &bytes) - if needsStyle { - if let style { - bytes.append(sgrBytes(for: style)) - } - needsStyle = false - } - appendVTPrintable(character, to: &bytes) - column += width - } - } - - private func shouldPinColumns( - for span: MobileTerminalRenderGridFrame.RowSpan - ) -> Bool { - guard span.hasWidthSensitiveScalars else { return false } - let characterCount = span.text.count - guard characterCount > 1 else { return false } - return true - } - - private func sourceCellWidths( - for text: String, - targetWidth: Int - ) -> [Int]? { - guard !text.isEmpty, targetWidth > 0 else { return nil } - var widths: [Int] = [] - var expandable: [Bool] = [] - var hasUntrustedExpansionCandidate = false - widths.reserveCapacity(text.count) - expandable.reserveCapacity(text.count) - for character in text { - let width = character.renderGridEstimatedCellWidth - let canExpand = character.canExpandForAmbiguousRenderGridWidth - widths.append(width) - expandable.append(canExpand) - if width == 1, - !canExpand, - character.unicodeScalars.contains(where: { - $0.value > 0x7F - && !$0.isRenderGridZeroWidthScalar - }) { - hasUntrustedExpansionCandidate = true - } - } - let total = widths.reduce(0, +) - if total < targetWidth { - guard !hasUntrustedExpansionCandidate else { - return nil - } - var remaining = targetWidth - total - for index in widths.indices where remaining > 0 && widths[index] < 2 { - guard expandable[index] else { - continue - } - widths[index] += 1 - remaining -= 1 - } - guard remaining == 0 else { - return nil - } - } else if total > targetWidth { - var excess = total - targetWidth - for index in widths.indices.reversed() where excess > 0 && widths[index] > 1 { - widths[index] -= 1 - excess -= 1 - } - guard excess == 0 else { - return nil - } - } - return widths - } - - private func appendCursor(row: Int?, column: Int, to bytes: inout Data) { - if let row { - bytes.append(0x1B) - bytes.append(0x5B) - appendDecimal(row + 1, to: &bytes) - bytes.append(0x3B) - appendDecimal(column + 1, to: &bytes) - bytes.append(0x48) - } else { - bytes.append(0x1B) - bytes.append(0x5B) - appendDecimal(column + 1, to: &bytes) - bytes.append(0x47) - } - } - - private func appendDecimal(_ value: Int, to bytes: inout Data) { - let value = max(0, value) - if value >= 10000 { - var divisor = 1 - while divisor <= value / 10 { - divisor *= 10 - } - var remaining = value - while divisor > 0 { - bytes.append(UInt8(48 + remaining / divisor)) - remaining %= divisor - divisor /= 10 - } - return - } - - if value >= 1000 { - bytes.append(UInt8(48 + value / 1000)) - bytes.append(UInt8(48 + value / 100 % 10)) - bytes.append(UInt8(48 + value / 10 % 10)) - bytes.append(UInt8(48 + value % 10)) - } else if value >= 100 { - bytes.append(UInt8(48 + value / 100)) - bytes.append(UInt8(48 + value / 10 % 10)) - bytes.append(UInt8(48 + value % 10)) - } else if value >= 10 { - bytes.append(UInt8(48 + value / 10)) - bytes.append(UInt8(48 + value % 10)) - } else { - bytes.append(UInt8(48 + value)) - } - } - - private func appendCursorRestore(_ bytes: inout Data) { - let defaultStyle = styleMapByID(frame.styles)[0] ?? .default - bytes.append(sgrBytes(for: defaultStyle)) - guard let cursor = frame.cursor else { - bytes.append(Data("\u{1B}[?25h".utf8)) - return - } - bytes.append(cursorStyleBytes(for: cursor)) - if cursor.visible { - bytes.append(Data("\u{1B}[?25h\u{1B}[\(cursor.row + 1);\(cursor.column + 1)H".utf8)) - } else { - bytes.append(Data("\u{1B}[?25l\u{1B}[\(cursor.row + 1);\(cursor.column + 1)H".utf8)) - } - } - - private func styleMapByID( - _ styles: [MobileTerminalRenderGridFrame.Style] - ) -> [Int: MobileTerminalRenderGridFrame.Style] { - var map: [Int: MobileTerminalRenderGridFrame.Style] = [:] - for style in styles { - map[style.id] = style - } - return map - } - - private func modeBytes(_ mode: MobileTerminalRenderGridFrame.ModeSetting) -> Data { - let prefix = mode.ansi ? "\u{1B}[" : "\u{1B}[?" - return Data("\(prefix)\(mode.code)\(mode.on ? "h" : "l")".utf8) - } - - - private func oscColorBytes(_ ps: Int, _ hex: String?) -> Data? { - guard let rgb = rgbComponents(hex) else { return nil } - let spec = String( - format: "rgb:%02x/%02x/%02x", - rgb.red, - rgb.green, - rgb.blue - ) - return Data("\u{1B}]\(ps);\(spec)\u{1B}\\".utf8) - } - - private func oscColorOrResetBytes(_ ps: Int, reset resetPs: Int, _ hex: String?) -> Data { - oscColorBytes(ps, hex) ?? Data("\u{1B}]\(resetPs)\u{1B}\\".utf8) - } - - private func appendVTPrintable(_ text: String, to bytes: inout Data) { - for scalar in text.unicodeScalars { - appendVTPrintable(scalar, to: &bytes) - } - } - - private func appendVTPrintable(_ character: Character, to bytes: inout Data) { - for scalar in character.unicodeScalars { - appendVTPrintable(scalar, to: &bytes) - } - } - - private func appendVTPrintable(_ scalar: UnicodeScalar, to bytes: inout Data) { - switch scalar.value { - case 0x20...0x7E, - 0xA0...0x10FFFF: - appendUTF8(scalar, to: &bytes) - default: - bytes.append(0x20) - } - } - - private func appendUTF8(_ scalar: UnicodeScalar, to bytes: inout Data) { - let value = scalar.value - if value <= 0x7F { - bytes.append(UInt8(value)) - } else if value <= 0x7FF { - bytes.append(UInt8(0xC0 | (value >> 6))) - bytes.append(UInt8(0x80 | (value & 0x3F))) - } else if value <= 0xFFFF { - bytes.append(UInt8(0xE0 | (value >> 12))) - bytes.append(UInt8(0x80 | ((value >> 6) & 0x3F))) - bytes.append(UInt8(0x80 | (value & 0x3F))) - } else { - bytes.append(UInt8(0xF0 | (value >> 18))) - bytes.append(UInt8(0x80 | ((value >> 12) & 0x3F))) - bytes.append(UInt8(0x80 | ((value >> 6) & 0x3F))) - bytes.append(UInt8(0x80 | (value & 0x3F))) - } - } - - private func cursorStyleBytes(for cursor: MobileTerminalRenderGridFrame.Cursor) -> Data { - let parameter: Int - switch cursor.style { - case .block, .blockHollow: - parameter = cursor.blinking ? 1 : 2 - case .underline: - parameter = cursor.blinking ? 3 : 4 - case .bar: - parameter = cursor.blinking ? 5 : 6 - } - return Data("\u{1B}[\(parameter) q".utf8) - } - -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridStyle+ColorSource.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridStyle+ColorSource.swift deleted file mode 100644 index 91440d70..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridStyle+ColorSource.swift +++ /dev/null @@ -1,15 +0,0 @@ -extension MobileTerminalRenderGridFrame.Style { - /// The terminal color source retained by a render-grid style. - /// - /// Keeping default and palette colors semantic lets a mirrored terminal - /// respond to later theme changes instead of baking the producer's - /// current resolved RGB value into every cell. - public enum ColorSource: String, Codable, Equatable, Sendable { - /// The terminal's current default foreground or background. - case defaultColor = "default" - /// An indexed terminal palette color. - case palette - /// A literal RGB color that must not change with the theme. - case rgb - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/NoopAnalytics.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/NoopAnalytics.swift deleted file mode 100644 index 88f15665..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/NoopAnalytics.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -/// An ``AnalyticsEmitting`` that drops every event. -/// -/// Use it as the default for SwiftUI previews, unit tests that don't assert on -/// analytics, and any call site that has no real emitter to inject. It does no -/// work, holds no state, and is safe to share across actors. -/// -/// ```swift -/// let store = MobileShellComposite(analytics: NoopAnalytics()) -/// ``` -public struct NoopAnalytics: AnalyticsEmitting { - /// Creates a no-op emitter. - public init() {} - - public func capture(_ event: String, _ properties: [String: AnalyticsValue]) {} - - public func identify(userId: String?, alias: String?, properties: [String: AnalyticsValue]) {} - - public func setSuperProperties(_ properties: [String: AnalyticsValue]) {} - - public func flush() async {} -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/TerminalGridSize.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/TerminalGridSize.swift deleted file mode 100644 index 51dc5496..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/TerminalGridSize.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// A terminal's rendering grid, expressed in both character cells and the -/// pixel dimensions those cells occupy. -/// -/// This is the value the paired-Mac surface reports back to the mobile client -/// on every attach, resize, and detach. ``columns`` and ``rows`` describe the -/// authoritative cell grid the daemon renders at; ``pixelWidth`` and -/// ``pixelHeight`` describe the surface's backing-store size in device pixels, -/// used by the host view to letterbox the rendered grid inside its container. -/// -/// All four fields are independent integers, so two grids are ``Equatable`` -/// only when their cell counts *and* pixel extents match. -/// -/// ```swift -/// let natural = TerminalGridSize(columns: 100, rows: 32, pixelWidth: 900, pixelHeight: 650) -/// ``` -public struct TerminalGridSize: Equatable, Hashable, Sendable, Codable { - /// The number of character columns in the grid. - public var columns: Int - /// The number of character rows in the grid. - public var rows: Int - /// The grid's backing-store width in device pixels. - public var pixelWidth: Int - /// The grid's backing-store height in device pixels. - public var pixelHeight: Int - - /// Creates a grid size from explicit cell counts and pixel dimensions. - /// - /// - Parameters: - /// - columns: The number of character columns. - /// - rows: The number of character rows. - /// - pixelWidth: The backing-store width in device pixels. - /// - pixelHeight: The backing-store height in device pixels. - public init(columns: Int, rows: Int, pixelWidth: Int, pixelHeight: Int) { - self.columns = columns - self.rows = rows - self.pixelWidth = pixelWidth - self.pixelHeight = pixelHeight - } -} diff --git a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/TerminalTheme.swift b/vendor/CMUXMobileCore/Sources/CMUXMobileCore/TerminalTheme.swift deleted file mode 100644 index fc933efe..00000000 --- a/vendor/CMUXMobileCore/Sources/CMUXMobileCore/TerminalTheme.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation - -/// A terminal color theme: the base background/foreground/cursor/selection -/// colors plus the ANSI palette. -/// -/// This is the canonical theme value the mobile terminal renders with. It is a -/// pure value type (no UIKit/AppKit) so it lives in `CMUXMobileCore` and can be -/// produced on the Mac, transported over the wire, and consumed by the embedded -/// libghostty runtime on iOS. Colors are stored as `#rrggbb` hex strings, the -/// same wire shape libghostty's config and the render-grid `Style` colors use. -/// -/// Use ``monokai`` as the built-in default when no theme has been supplied. -public struct TerminalTheme: Codable, Equatable, Sendable { - /// Terminal background color (`#rrggbb`). - public var background: String - /// Terminal foreground color (`#rrggbb`). - public var foreground: String - /// Ghostty bold-color behavior (`bright` or `#rrggbb`), or `nil` when unset. - public var boldColor: String? - /// Cursor color (`#rrggbb`). - public var cursor: String - /// Cell-relative cursor-color semantics, when configured. - public var cursorColorSemantic: CellRelativeColor? - /// Cursor text color (`#rrggbb`), or `nil` to let the terminal derive one. - public var cursorText: String? - /// Cell-relative cursor-text semantics, when configured. - public var cursorTextSemantic: CellRelativeColor? - /// Selection background color (`#rrggbb`). - public var selectionBackground: String - /// Cell-relative selection-background semantics, when configured. - public var selectionBackgroundSemantic: CellRelativeColor? - /// Selection foreground color (`#rrggbb`). - public var selectionForeground: String - /// Cell-relative selection-foreground semantics, when configured. - public var selectionForegroundSemantic: CellRelativeColor? - /// The ANSI palette, either its 16-color base or all 256 entries. - /// - /// Indices 0-7 are the normal colors and 8-15 are the bright variants, in - /// the standard order: black, red, green, yellow, blue, magenta, cyan, white. - public var palette: [String] - - /// The number of palette entries a valid theme must carry. - public static let paletteCount = 16 - /// The number of entries in a complete Ghostty palette snapshot. - public static let extendedPaletteCount = 256 - - public init( - background: String, - foreground: String, - boldColor: String? = nil, - cursor: String, - cursorColorSemantic: CellRelativeColor? = nil, - cursorText: String? = nil, - cursorTextSemantic: CellRelativeColor? = nil, - selectionBackground: String, - selectionBackgroundSemantic: CellRelativeColor? = nil, - selectionForeground: String, - selectionForegroundSemantic: CellRelativeColor? = nil, - palette: [String] - ) { - self.background = background - self.foreground = foreground - self.boldColor = boldColor - self.cursor = cursor - self.cursorColorSemantic = cursorColorSemantic - self.cursorText = cursorText - self.cursorTextSemantic = cursorTextSemantic - self.selectionBackground = selectionBackground - self.selectionBackgroundSemantic = selectionBackgroundSemantic - self.selectionForeground = selectionForeground - self.selectionForegroundSemantic = selectionForegroundSemantic - self.palette = palette - } - - /// Whether every color string parses and the palette has exactly 16 entries. - public var isValid: Bool { - guard palette.count == Self.paletteCount || palette.count == Self.extendedPaletteCount else { return false } - if let boldColor, - boldColor.lowercased() != "bright", - Self.rgbComponents(boldColor) == nil { - return false - } - var colors = [background, foreground, cursor, selectionBackground, selectionForeground] - colors.append(contentsOf: palette) - if let cursorText { colors.append(cursorText) } - return colors.allSatisfy { Self.rgbComponents($0) != nil } - } - - /// Parses a `#rrggbb` (or `rrggbb`) hex string into 0-255 RGB components, - /// or `nil` when the string is not a valid 6-digit hex color. - public static func rgbComponents(_ value: String?) -> (red: Int, green: Int, blue: Int)? { - guard var value else { return nil } - if value.hasPrefix("#") { - value.removeFirst() - } - guard value.count == 6, let raw = Int(value, radix: 16) else { return nil } - return ((raw >> 16) & 0xFF, (raw >> 8) & 0xFF, raw & 0xFF) - } - - /// Normalizes a hex color to canonical `#rrggbb` form, or `nil` when it does - /// not parse. `rgbComponents` accepts a bare `rrggbb`, so this re-emits the - /// `#`-prefixed form the theme contract (and ghostty directives) expect. - static func canonicalHex(_ value: String?) -> String? { - guard let rgb = rgbComponents(value) else { return nil } - return String(format: "#%02x%02x%02x", rgb.red, rgb.green, rgb.blue) - } - - /// The ghostty config directives that express this theme's colors, one per - /// line. Suitable for appending to an iOS ghostty config file. - /// - /// Only colors that parse are emitted, so a partially-invalid theme still - /// produces a usable (if incomplete) config rather than corrupt directives. - public var ghosttyColorDirectives: String { - var lines: [String] = [] - if let bg = Self.canonicalHex(background) { lines.append("background = \(bg)") } - if let fg = Self.canonicalHex(foreground) { lines.append("foreground = \(fg)") } - if let boldColor { - if boldColor.lowercased() == "bright" { - lines.append("bold-color = bright") - } else if let color = Self.canonicalHex(boldColor) { - lines.append("bold-color = \(color)") - } - } - if let cursorColorSemantic { - lines.append("cursor-color = \(cursorColorSemantic.rawValue)") - } else if let cur = Self.canonicalHex(cursor) { - lines.append("cursor-color = \(cur)") - } - if let cursorTextSemantic { - lines.append("cursor-text = \(cursorTextSemantic.rawValue)") - } else if let cursorText, let curText = Self.canonicalHex(cursorText) { - lines.append("cursor-text = \(curText)") - } - if let selectionBackgroundSemantic { - lines.append("selection-background = \(selectionBackgroundSemantic.rawValue)") - } else if let selBg = Self.canonicalHex(selectionBackground) { - lines.append("selection-background = \(selBg)") - } - if let selectionForegroundSemantic { - lines.append("selection-foreground = \(selectionForegroundSemantic.rawValue)") - } else if let selFg = Self.canonicalHex(selectionForeground) { - lines.append("selection-foreground = \(selFg)") - } - for (index, color) in palette.enumerated() { - if let hex = Self.canonicalHex(color) { - lines.append("palette = \(index)=\(hex)") - } - } - return lines.joined(separator: "\n") - } - - /// Returns this theme if it validates, otherwise ``monokai``. Use this to - /// resolve an untrusted or partially-decoded theme to a renderable one. - public func validatedOrDefault() -> TerminalTheme { - isValid ? self : .monokai - } - - /// The built-in Monokai theme, used as the default when no theme is supplied. - public static let monokai = TerminalTheme( - background: "#272822", - foreground: "#fdfff1", - cursor: "#c0c1b5", - cursorText: nil, - selectionBackground: "#57584f", - selectionForeground: "#fdfff1", - palette: [ - "#272822", // 0 black - "#f92672", // 1 red - "#a6e22e", // 2 green - "#e6db74", // 3 yellow - "#fd971f", // 4 blue - "#ae81ff", // 5 magenta - "#66d9ef", // 6 cyan - "#fdfff1", // 7 white - "#6e7066", // 8 bright black - "#f92672", // 9 bright red - "#a6e22e", // 10 bright green - "#e6db74", // 11 bright yellow - "#fd971f", // 12 bright blue - "#ae81ff", // 13 bright magenta - "#66d9ef", // 14 bright cyan - "#fdfff1", // 15 bright white - ] - ) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCodingTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCodingTests.swift deleted file mode 100644 index f3d5eeaa..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCodingTests.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -/// Round-trip coverage for ``CmxAttachTicket`` wire compatibility. -/// -/// The mac side of PR 5079 already speaks the current mixed-key shape -/// (camelCase fields plus `auth_token`). These tests pin the encode bytes to -/// that shape and prove the tolerant decoder accepts both the current -/// `auth_token` key and a normalized `authToken` key. - -private func makeRoutes() throws -> [CmxAttachRoute] { - [ - try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831), - priority: 1 - ), - ] -} - -private func futureExpiry() -> Date { - Date(timeIntervalSince1970: 4_000_000_000) -} - -private func canonicalEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - return encoder -} - -private func canonicalDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder -} - -@Test func attachTicketEncodesAuthTokenUnderSnakeCaseKey() throws { - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: "terminal-9", - macDeviceID: "mac-1", - macDisplayName: "Studio", - routes: makeRoutes(), - expiresAt: futureExpiry(), - authToken: "ticket-secret" - ) - - let data = try canonicalEncoder().encode(ticket) - let json = try #require(String(data: data, encoding: .utf8)) - - // The auth token field stays on the historical snake_case key so the mac - // side keeps decoding it; the other fields stay camelCase. - #expect(json.contains("\"auth_token\":\"ticket-secret\"")) - #expect(!json.contains("\"authToken\"")) - #expect(json.contains("\"workspaceID\":\"workspace-1\"")) - #expect(json.contains("\"terminalID\":\"terminal-9\"")) - #expect(json.contains("\"macDeviceID\":\"mac-1\"")) - #expect(json.contains("\"macDisplayName\":\"Studio\"")) - #expect(json.contains("\"expiresAt\"")) -} - -@Test func attachTicketRoundTripsThroughCanonicalEncoder() throws { - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: makeRoutes(), - expiresAt: futureExpiry(), - authToken: "ticket-secret" - ) - - let data = try canonicalEncoder().encode(ticket) - let decoded = try canonicalDecoder().decode(CmxAttachTicket.self, from: data) - #expect(decoded == ticket) -} - -@Test func attachTicketDecodesCurrentSnakeCaseAuthTokenShape() throws { - // The exact mixed shape the mac side emits today. - let json = """ - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": "terminal-3", - "macDeviceID": "mac-1", - "macDisplayName": "Studio", - "routes": [ - { "id": "tailscale", "kind": "tailscale", - "endpoint": { "type": "host_port", "host": "100.64.1.2", "port": 49831 }, - "priority": 1 } - ], - "expiresAt": "2096-10-02T07:06:40Z", - "auth_token": "ticket-secret" - } - """ - - let decoded = try canonicalDecoder().decode( - CmxAttachTicket.self, - from: try #require(json.data(using: .utf8)) - ) - #expect(decoded.authToken == "ticket-secret") - #expect(decoded.workspaceID == "workspace-1") - #expect(decoded.terminalID == "terminal-3") -} - -@Test func attachTicketDecodesNormalizedCamelCaseAuthTokenShape() throws { - // A future normalized producer that moves the token onto a camelCase key - // must still decode. - let json = """ - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [ - { "id": "tailscale", "kind": "tailscale", - "endpoint": { "type": "host_port", "host": "100.64.1.2", "port": 49831 }, - "priority": 1 } - ], - "expiresAt": "2096-10-02T07:06:40Z", - "authToken": "ticket-secret" - } - """ - - let decoded = try canonicalDecoder().decode( - CmxAttachTicket.self, - from: try #require(json.data(using: .utf8)) - ) - #expect(decoded.authToken == "ticket-secret") -} - -@Test func attachTicketPrefersSnakeCaseAuthTokenWhenBothKeysPresent() throws { - let json = """ - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [ - { "id": "tailscale", "kind": "tailscale", - "endpoint": { "type": "host_port", "host": "100.64.1.2", "port": 49831 }, - "priority": 1 } - ], - "expiresAt": "2096-10-02T07:06:40Z", - "auth_token": "canonical-secret", - "authToken": "camel-secret" - } - """ - - let decoded = try canonicalDecoder().decode( - CmxAttachTicket.self, - from: try #require(json.data(using: .utf8)) - ) - #expect(decoded.authToken == "canonical-secret") -} - -@Test func attachTicketDecodesMissingAuthTokenAsNil() throws { - let json = """ - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [ - { "id": "tailscale", "kind": "tailscale", - "endpoint": { "type": "host_port", "host": "100.64.1.2", "port": 49831 }, - "priority": 1 } - ], - "expiresAt": "2096-10-02T07:06:40Z" - } - """ - - let decoded = try canonicalDecoder().decode( - CmxAttachTicket.self, - from: try #require(json.data(using: .utf8)) - ) - #expect(decoded.authToken == nil) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift deleted file mode 100644 index dd5ac948..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketCompactCoderTests.swift +++ /dev/null @@ -1,425 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -/// Round-trip and cross-grammar coverage for ``CmxAttachTicketCompactCoder``. -/// -/// The pairing QR moved from the legacy full-key `Codable` JSON to a compact -/// short-key grammar. These tests pin the compact encode shape (short keys, -/// dropped empties, no auth token, no display name, no expiry), prove -/// lossless round trips of what the grammar keeps, and pin the compatibility -/// matrix: a new decoder accepts the current grammar, the first compact -/// revision (extra `e`/`n` keys, explicit route ids and endpoint types), and -/// the legacy full-key grammar via the input router, while the legacy decoder -/// rejects compact payloads with a thrown error rather than a silently wrong -/// ticket. - -private let compactCoder = CmxAttachTicketCompactCoder() -private let compactCanonicalEndpointID = String(repeating: "c", count: 64) - -private func encodeLegacyCompatibility(_ ticket: CmxAttachTicket) throws -> Data { - try compactCoder.encode( - ticket, - routeDisclosureMode: .legacyPrivateNetworkCompatibility - ) -} - -private func wholeSecondFutureExpiry() -> Date { - Date(timeIntervalSince1970: 4_000_000_000) -} - -private func hostPortRoute(priority: Int = 0) throws -> CmxAttachRoute { - try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831), - priority: priority - ) -} - -private func legacyDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder -} - -@Test func compactEncodeUsesShortKeysAndNeverCarriesAuthTokenNameOrExpiry() throws { - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: "terminal-9", - macDeviceID: "mac-1", - macDisplayName: "Studio", - macUserEmail: "user@example.com", - macUserID: "user_mac_123", - macPairingCompatibilityVersion: 1, - macAppVersion: "0.64.15", - macAppBuild: "42", - routes: [try hostPortRoute(priority: 1)], - expiresAt: wholeSecondFutureExpiry(), - authToken: "ticket-secret" - ) - - let data = try encodeLegacyCompatibility(ticket) - let json = try #require(String(data: data, encoding: .utf8)) - - #expect(!json.contains("auth_token")) - #expect(!json.contains("authToken")) - #expect(!json.contains("ticket-secret")) - #expect(!json.contains("workspaceID")) - #expect(!json.contains("version")) - #expect(json.contains("\"v\":1")) - #expect(json.contains("\"w\":\"workspace-1\"")) - #expect(json.contains("\"d\":\"mac-1\"")) - #expect(!json.contains("user@example.com")) - #expect(json.contains("\"u\":\"user_mac_123\"")) - #expect(json.contains("\"pc\":1")) - #expect(json.contains("\"av\":\"0.64.15\"")) - #expect(json.contains("\"ab\":\"42\"")) - // The grammar no longer carries the display name or an expiry: the name - // arrives post-handshake via `mobile.host.status`, and a pairing QR never - // expires. - #expect(!json.contains("Studio")) - #expect(!json.contains("4000000000")) - let object = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - #expect(object["n"] == nil) - #expect(object["e"] == nil) -} - -@Test func compactRoundTripsFullFieldTicketExceptDroppedQRFields() throws { - let routes = [ - try hostPortRoute(priority: 2), - try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: try CmxIrohPeerIdentity(endpointID: compactCanonicalEndpointID), - pathHints: [ - try CmxIrohPathHint( - kind: .relayIdentifier, - value: "use1", - source: .native, - privacyScope: .publicInternet - ), - try CmxIrohPathHint( - kind: .directAddress, - value: "192.168.1.4:4242", - source: .lan, - privacyScope: .localNetwork, - observedAt: wholeSecondFutureExpiry().addingTimeInterval(-60), - expiresAt: wholeSecondFutureExpiry(), - networkProfile: CmxIrohNetworkProfileKey( - source: .lan, - profileID: String(repeating: "b", count: 64) - ) - ), - try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example", - source: .native, - privacyScope: .publicInternet - ), - ] - ), - priority: 1 - ), - try CmxAttachRoute( - id: "ws", - kind: .websocket, - endpoint: .url("wss://example.com/attach") - ), - ] - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: "terminal-9", - macDeviceID: "mac-1", - macDisplayName: "Studio", - macUserEmail: "user@example.com", - macUserID: "user_mac_123", - macPairingCompatibilityVersion: 1, - macAppVersion: "0.64.15", - macAppBuild: "42", - routes: routes, - expiresAt: wholeSecondFutureExpiry(), - authToken: "ticket-secret" - ) - - let encoded = try encodeLegacyCompatibility(ticket) - let json = try #require(String(data: encoded, encoding: .utf8)) - #expect(json.contains(compactCanonicalEndpointID)) - #expect(!json.contains("\"ph\"")) - #expect(!json.contains("\"rh\"")) - #expect(!json.contains("\"ru\"")) - #expect(!json.contains("\"da\"")) - #expect(!json.contains("192.168.1.4")) - #expect(!json.contains("network_profile")) - #expect(!json.contains("relay.example")) - #expect(!json.contains("use1")) - - let decoded = try compactCoder.decode(encoded) - - #expect(decoded.version == ticket.version) - #expect(decoded.workspaceID == ticket.workspaceID) - #expect(decoded.terminalID == ticket.terminalID) - #expect(decoded.macDeviceID == ticket.macDeviceID) - #expect(decoded.macUserEmail == nil) - #expect(decoded.macUserID == ticket.macUserID) - #expect(decoded.macPairingCompatibilityVersion == ticket.macPairingCompatibilityVersion) - #expect(decoded.macAppVersion == ticket.macAppVersion) - #expect(decoded.macAppBuild == ticket.macAppBuild) - #expect(decoded.routes.map(\.id) == ticket.routes.map(\.id)) - guard case let .peer(decodedIdentity, decodedHints) = decoded.routes[1].endpoint else { - Issue.record("Expected compact Iroh peer route") - return - } - #expect(decodedIdentity.endpointID == compactCanonicalEndpointID) - #expect(decodedHints.isEmpty) - #expect(decoded.routes[0] == ticket.routes[0]) - #expect(decoded.routes[2] == ticket.routes[2]) - // Dropped by design: the auth token never authorizes anything, the name - // arrives via `mobile.host.status`, and a pairing QR never expires. - #expect(decoded.authToken == nil) - #expect(decoded.macDisplayName == nil) - #expect(decoded.expiresAt == nil) - #expect(!decoded.isExpired(at: .distantFuture)) -} - -@Test func compactDecodeKeepsLegacyEmailPayloadsWorking() throws { - let legacyEmailPayload = """ - {"v":1,"d":"mac-1","u":"user@example.com","r":[{"k":"tailscale","e":{"h":"100.64.1.2","p":49831}}]} - """ - - let decoded = try compactCoder.decode(Data(legacyEmailPayload.utf8)) - - #expect(decoded.macUserEmail == "user@example.com") - #expect(decoded.macUserID == nil) -} - -@Test func compactRoundTripsMacWidePairingTicketAndDropsEmptyFields() throws { - // The shape the pairing window mints: Mac-wide (empty workspaceID), no - // terminal scope. - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [try hostPortRoute()], - expiresAt: wholeSecondFutureExpiry(), - authToken: "ticket-secret" - ) - - let data = try encodeLegacyCompatibility(ticket) - let object = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - // Empty workspaceID, nil terminalID, the display name, and the expiry - // are all omitted. - #expect(object["w"] == nil) - #expect(object["t"] == nil) - #expect(object["n"] == nil) - #expect(object["e"] == nil) - let route = try #require((object["r"] as? [[String: Any]])?.first) - // priority 0 is the default and is omitted from the route, the id - // "tailscale" matches what the decoder resynthesizes from the kind, and - // the endpoint type is implied by `h` + `p`. - #expect(route["p"] == nil) - #expect(route["i"] == nil) - let endpoint = try #require(route["e"] as? [String: Any]) - #expect(endpoint["t"] == nil) - - let decoded = try compactCoder.decode(data) - #expect(decoded.workspaceID == "") - #expect(decoded.terminalID == nil) - #expect(decoded.macDisplayName == nil) - #expect(decoded.routes == ticket.routes) -} - -@Test func compactRoundTripsRepeatedKindAndCustomRouteIDs() throws { - // First tailscale route gets the synthesized id "tailscale", the second - // "tailscale_2" (both omitted on the wire); the custom "vpn-backup" id - // differs from any synthesized id, so it rides verbatim. - let routes = [ - try hostPortRoute(), - try CmxAttachRoute( - id: "tailscale_2", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.3", port: 49832) - ), - try CmxAttachRoute( - id: "vpn-backup", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.4", port: 49833) - ), - ] - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: routes - ) - - let data = try encodeLegacyCompatibility(ticket) - let object = try #require( - try JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - let encodedRoutes = try #require(object["r"] as? [[String: Any]]) - #expect(encodedRoutes.count == 3) - #expect(encodedRoutes[0]["i"] == nil) - #expect(encodedRoutes[1]["i"] == nil) - #expect(encodedRoutes[2]["i"] as? String == "vpn-backup") - - let decoded = try compactCoder.decode(data) - #expect(decoded.routes == routes) -} - -@Test func compactDecodeAcceptsFirstRevisionPayloadAndDropsExpiryAndName() throws { - // A QR minted by the first compact revision: expiry under `e` (already in - // the past), display name under `n`, explicit route `i`, and explicit - // endpoint type `t`. It must keep pairing: the stale expiry and the name - // are dropped, the explicit ids and types are honored. - let firstRevision = Data(""" - {"d":"mac-1","e":1000,"n":"Studio","r":[{"e":{"h":"100.64.1.2","p":49831,"t":"host_port"},"i":"tailscale","k":"tailscale"}],"v":1} - """.utf8) - - let decoded = try compactCoder.decode(firstRevision) - #expect(decoded.macDeviceID == "mac-1") - #expect(decoded.macDisplayName == nil) - #expect(decoded.expiresAt == nil) - #expect(!decoded.isExpired(at: .distantFuture)) - let expectedRoutes = [try hostPortRoute()] - #expect(decoded.routes == expectedRoutes) -} - -@Test func compactDecodeKeepsFirstRevisionIrohHintFieldsReadable() throws { - let firstRevision = Data(""" - {"d":"mac-1","r":[{"e":{"da":["8.8.8.8:4242"],"i":"\(compactCanonicalEndpointID)","rh":"use1","ru":"https://relay.example/","t":"peer"},"i":"iroh","k":"iroh"}],"v":1} - """.utf8) - - let decoded = try compactCoder.decode(firstRevision) - guard case let .peer(identity, pathHints) = decoded.routes.first?.endpoint else { - Issue.record("Expected legacy compact Iroh peer route") - return - } - #expect(identity.endpointID == compactCanonicalEndpointID) - #expect(pathHints.map(\.kind) == [.relayIdentifier, .directAddress, .relayURL]) - #expect(pathHints.first { $0.kind == .directAddress }?.isUsable(at: .distantPast) == false) -} - -@Test func legacyDecoderRejectsCompactPayloadLoudly() throws { - // Old-phone-scans-new-QR: the pre-compact decoder must throw (missing - // "version" key), never silently produce a wrong ticket. - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: "Studio", - routes: [try hostPortRoute()], - expiresAt: wholeSecondFutureExpiry() - ) - let compact = try encodeLegacyCompatibility(ticket) - - #expect(throws: DecodingError.self) { - try legacyDecoder().decode(CmxAttachTicket.self, from: compact) - } -} - -@Test func compactDecoderRejectsLegacyPayload() throws { - // The compact decoder is never handed a legacy payload in production - // (the input router checks `isCompactPayload` first), but if it were it - // must throw, not mis-decode. - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [try hostPortRoute()], - expiresAt: wholeSecondFutureExpiry() - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let legacy = try encoder.encode(ticket) - - #expect(compactCoder.isCompactPayload(legacy) == false) - #expect(throws: DecodingError.self) { - try compactCoder.decode(legacy) - } -} - -@Test func compactPayloadDetectionDistinguishesGrammars() throws { - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [try hostPortRoute()], - expiresAt: wholeSecondFutureExpiry() - ) - let compact = try encodeLegacyCompatibility(ticket) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let legacy = try encoder.encode(ticket) - - #expect(compactCoder.isCompactPayload(compact)) - #expect(!compactCoder.isCompactPayload(legacy)) - #expect(!compactCoder.isCompactPayload(Data("not json".utf8))) -} - -@Test func compactDecodeRejectsUnsupportedPayloadVersion() throws { - // The decoded `v` must reach validation: a future compact grammar - // revision that bumps the version has to fail loudly on today's phones, - // not silently misdecode as a version-1 ticket. - let futureVersion = Data(""" - {"v":2,"d":"mac-1","r":[{"k":"tailscale","e":{"h":"100.64.1.2","p":49831}}]} - """.utf8) - #expect(throws: CmxAttachTicketError.unsupportedVersion(2)) { - try compactCoder.decode(futureVersion) - } -} - -@Test func compactDecodeRejectsUnknownRouteKindAndEndpointType() throws { - let unknownKind = Data(""" - {"v":1,"d":"mac-1","e":4000000000,"r":[{"i":"x","k":"carrier-pigeon","e":{"t":"host_port","h":"100.64.1.2","p":49831}}]} - """.utf8) - #expect(throws: DecodingError.self) { - try compactCoder.decode(unknownKind) - } - - let unknownEndpoint = Data(""" - {"v":1,"d":"mac-1","e":4000000000,"r":[{"i":"tailscale","k":"tailscale","e":{"t":"smoke-signal"}}]} - """.utf8) - #expect(throws: DecodingError.self) { - try compactCoder.decode(unknownEndpoint) - } -} - -@Test func compactPayloadIsSmallerThanLegacyPayload() throws { - // The point of the grammar: the same Mac-wide pairing ticket (with the - // auth token the store mints today) must shrink enough to drop QR - // versions. Pin a ceiling near the 150-byte target so payload growth - // shows up in review. - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: UUID().uuidString, - macDisplayName: "Lawrence's MacBook Pro", - routes: [ - try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.102.73.120", port: 49831) - ), - ], - expiresAt: wholeSecondFutureExpiry(), - authToken: "3q2-7wDqzfQqzKpQ4XB8x1n0o5pYkz9jW2sT8uVbLwM" - ) - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let legacy = try encoder.encode(ticket) - let compact = try encodeLegacyCompatibility(ticket) - - #expect(compact.count < legacy.count) - #expect(compact.count <= 150) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketDisclosureTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketDisclosureTests.swift deleted file mode 100644 index 53eb7b4e..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketDisclosureTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func authenticatedTicketDisclosurePreservesFieldsAndFiltersRoutes() throws { - let now = Date(timeIntervalSince1970: 2_000_000_000) - let currentHint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test/", - source: .native, - privacyScope: .publicInternet - ) - let expiredHint = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.2:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: now.addingTimeInterval(-120), - expiresAt: now.addingTimeInterval(-60), - networkProfile: CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: String(repeating: "a", count: 64) - ) - ) - let route = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ), - pathHints: [expiredHint, currentHint] - ), - priority: 7 - ) - let ticket = try CmxAttachTicket( - workspaceID: "workspace", - terminalID: "terminal", - macDeviceID: "mac-device", - macDisplayName: "Mac", - macUserEmail: "owner@example.test", - macUserID: "user-id", - macPairingCompatibilityVersion: 4, - macAppVersion: "1.2.3", - macAppBuild: "456", - routes: [route], - expiresAt: now.addingTimeInterval(300), - authToken: "attach-token" - ) - - let disclosed = try ticket.authenticatedDisclosure(at: now) - let disclosedRoute = try #require(route.disclosed(for: .authenticated, at: now)) - let expected = try CmxAttachTicket( - version: ticket.version, - workspaceID: ticket.workspaceID, - terminalID: ticket.terminalID, - macDeviceID: ticket.macDeviceID, - macDisplayName: ticket.macDisplayName, - macUserEmail: ticket.macUserEmail, - macUserID: ticket.macUserID, - macPairingCompatibilityVersion: ticket.macPairingCompatibilityVersion, - macAppVersion: ticket.macAppVersion, - macAppBuild: ticket.macAppBuild, - routes: [disclosedRoute], - expiresAt: ticket.expiresAt, - authToken: ticket.authToken - ) - - #expect(disclosed == expected) - guard case let .peer(_, pathHints) = disclosed.routes[0].endpoint else { - Issue.record("Expected an Iroh peer route") - return - } - #expect(pathHints == [currentHint]) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift deleted file mode 100644 index e6cc74cc..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxAttachTicketIrohQRCodeTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -private let compactIrohQRCoder = CmxAttachTicketCompactCoder() -private let compactIrohQREndpointID = String(repeating: "c", count: 64) - -private func compactIrohQRExpiry() -> Date { - Date(timeIntervalSince1970: 4_000_000_000) -} - -private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute { - try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ) -} - -@Test func identityOnlyQRModeKeepsOnlyIrohIdentityAndRejectsTicketsWithoutIt() throws { - let privateAddress = "100.64.1.2:49152" - let relayURL = "https://relay.attacker.example/" - let websocketURL = "wss://private.example/connect?token=secret" - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity(endpointID: compactIrohQREndpointID), - pathHints: [ - CmxIrohPathHint( - kind: .directAddress, - value: privateAddress, - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: compactIrohQRExpiry().addingTimeInterval(-60), - expiresAt: compactIrohQRExpiry(), - networkProfile: CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: String(repeating: "a", count: 64) - ) - ), - CmxIrohPathHint( - kind: .relayURL, - value: relayURL, - source: .native, - privacyScope: .publicInternet - ), - ] - ) - ) - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [ - compactIrohQRHostPortRoute(), - iroh, - CmxAttachRoute( - id: "websocket", - kind: .websocket, - endpoint: .url(websocketURL) - ), - ] - ) - - let encoded = try compactIrohQRCoder.encode( - ticket, - routeDisclosureMode: .irohIdentityOnly - ) - let json = try #require(String(data: encoded, encoding: .utf8)) - #expect(json.contains(compactIrohQREndpointID)) - #expect(!json.contains(privateAddress)) - #expect(!json.contains(relayURL)) - #expect(!json.contains(websocketURL)) - #expect(!json.contains("\"h\"")) - #expect(!json.contains("\"u\":\"wss")) - #expect(!json.contains("\"ph\"")) - - let decoded = try compactIrohQRCoder.decode(encoded) - #expect(decoded.routes.count == 1) - #expect(decoded.routes.first?.id == iroh.id) - guard case let .peer(identity, hints) = decoded.routes.first?.endpoint else { - Issue.record("Expected identity-only Iroh route") - return - } - #expect(identity.endpointID == compactIrohQREndpointID) - #expect(hints.isEmpty) - #expect(CmxPairingQRCode().encode( - ticket, - routeDisclosureMode: .irohIdentityOnly - ) == nil) - - let tailscaleOnly = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [compactIrohQRHostPortRoute()] - ) - #expect(throws: CmxAttachTicketCompactCoderError.noRoutesForDisclosureMode( - .irohIdentityOnly - )) { - _ = try compactIrohQRCoder.encode( - tailscaleOnly, - routeDisclosureMode: .irohIdentityOnly - ) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxCredentialedHTTPSessionTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxCredentialedHTTPSessionTests.swift deleted file mode 100644 index 0d358575..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxCredentialedHTTPSessionTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct CmxCredentialedHTTPSessionTests { - @Test func rejects307CredentialHeadersAndBody() throws { - let source = try #require(URL(string: "https://cmux.example/api/devices")) - let destination = try #require(URL(string: "https://attacker.example/capture")) - var redirected = URLRequest(url: destination) - redirected.httpMethod = "POST" - redirected.setValue("Bearer access", forHTTPHeaderField: "Authorization") - redirected.setValue("refresh-secret", forHTTPHeaderField: "X-Stack-Refresh-Token") - redirected.httpBody = Data(#"{"secret":"body-secret"}"#.utf8) - let response = try #require(HTTPURLResponse( - url: source, - statusCode: 307, - httpVersion: nil, - headerFields: ["Location": destination.absoluteString] - )) - let session = URLSession(configuration: .ephemeral) - let task = session.dataTask(with: source) - var completionCalled = false - var forwardedRequest: URLRequest? = redirected - - CmxCredentialedHTTPRedirectDelegate().urlSession( - session, - task: task, - willPerformHTTPRedirection: response, - newRequest: redirected - ) { request in - completionCalled = true - forwardedRequest = request - } - - #expect(completionCalled) - #expect(forwardedRequest == nil) - } - - @Test func rejectsDeclaredOversizedResponseBeforeBufferingIt() async throws { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [OversizedCredentialedHTTPURLProtocol.self] - let session = CmxCredentialedHTTPSession(configuration: configuration) - let url = try #require(URL(string: "https://cmux.example/api/devices")) - - await #expect(throws: CmxCredentialedHTTPSessionError.responseTooLarge) { - _ = try await session.data(for: URLRequest(url: url)) - } - } - - @Test func rejectsOversizedResponseWithoutDeclaredLength() async throws { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [UndeclaredOversizedCredentialedHTTPURLProtocol.self] - let session = CmxCredentialedHTTPSession( - configuration: configuration, - maximumResponseByteCount: 8 - ) - let url = try #require(URL(string: "https://cmux.example/api/devices")) - - await #expect(throws: CmxCredentialedHTTPSessionError.responseTooLarge) { - _ = try await session.data(for: URLRequest(url: url)) - } - } -} - -private final class OversizedCredentialedHTTPURLProtocol: URLProtocol, @unchecked Sendable { - override class func canInit(with _: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Length": "4194305"] - ) else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("must-not-buffer".utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} -} - -private final class UndeclaredOversizedCredentialedHTTPURLProtocol: URLProtocol, @unchecked Sendable { - override class func canInit(with _: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - - override func startLoading() { - guard let url = request.url, - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: [:] - ) else { - client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) - return - } - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("ninebytes".utf8)) - client?.urlProtocolDidFinishLoading(self) - } - - override func stopLoading() {} -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxDeviceIDCanonicalizationTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxDeviceIDCanonicalizationTests.swift deleted file mode 100644 index b4dde507..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxDeviceIDCanonicalizationTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct CmxDeviceIDCanonicalizationTests { - private let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" - private let lowercaseUUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" - - @Test func canonicalizerLowercasesOnlyUUIDDeviceIDs() { - #expect(cmxCanonicalDeviceID(uppercaseUUID) == lowercaseUUID) - #expect(cmxCanonicalDeviceID(lowercaseUUID) == lowercaseUUID) - #expect(cmxCanonicalDeviceID("Legacy-Mac-ID") == "Legacy-Mac-ID") - #expect(cmxCanonicalDeviceID(" legacy-id ") == " legacy-id ") - } - - @Test func attachTicketCanonicalizesInitializerAndLegacyWireIdentity() throws { - let route = try CmxAttachRoute( - id: "manual", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.0.2", port: 58_465) - ) - let initialized = try CmxAttachTicket( - workspaceID: "workspace", - terminalID: nil, - macDeviceID: uppercaseUUID, - macDisplayName: "Studio", - routes: [route] - ) - #expect(initialized.macDeviceID == lowercaseUUID) - - let data = Data(""" - { - "version": 1, - "workspaceID": "workspace", - "macDeviceID": "\(uppercaseUUID)", - "routes": [ - { - "id": "manual", - "kind": "tailscale", - "endpoint": { "type": "host_port", "host": "100.64.0.2", "port": 58465 }, - "priority": 0 - } - ] - } - """.utf8) - let decoded = try JSONDecoder().decode(CmxAttachTicket.self, from: data) - #expect(decoded.macDeviceID == lowercaseUUID) - } - - @Test func pairingPayloadCanonicalizesUUIDAndPreservesOpaqueIdentity() throws { - let expiresAt = Date().addingTimeInterval(60) - let uuidPayload = try MobileSyncPairingPayload( - macDeviceID: uppercaseUUID, - macDisplayName: nil, - host: "100.64.0.2", - port: 58_465, - expiresAt: expiresAt, - transport: .tailscale - ) - let opaquePayload = try MobileSyncPairingPayload( - macDeviceID: "Legacy-Mac-ID", - macDisplayName: nil, - host: "100.64.0.2", - port: 58_465, - expiresAt: expiresAt, - transport: .tailscale - ) - - #expect(uuidPayload.macDeviceID == lowercaseUUID) - #expect(opaquePayload.macDeviceID == "Legacy-Mac-ID") - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohCustomPrivateAddressTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohCustomPrivateAddressTests.swift deleted file mode 100644 index 82d94259..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohCustomPrivateAddressTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func customPrivateAddressCanonicalizesNumericIPOnly() throws { - let ipv4 = try CmxIrohCustomPrivateAddress("10.0.0.8") - #expect(ipv4.value == "10.0.0.8") - #expect(ipv4.family == .ipv4) - #expect(ipv4.socketAddress(port: 49_152) == "10.0.0.8:49152") - - let ipv6 = try CmxIrohCustomPrivateAddress("fd00:0:0:0:0:0:0:8") - #expect(ipv6.value == "fd00::8") - #expect(ipv6.family == .ipv6) - #expect(ipv6.socketAddress(port: 49_152) == "[fd00::8]:49152") -} - -@Test func customPrivateAddressRejectsCoordinatesAndUnsafeAddresses() { - for value in [ - "private.example.com", - "10.0.0.8:49152", - "[fd00::8]:49152", - "127.0.0.1", - "::1", - "0.0.0.0", - "::", - "169.254.1.2", - "fe80::1", - "ff02::1", - "fd00::1%en0", - ] { - #expect( - throws: CmxIrohCustomPrivateAddressError.invalidAddress, - Comment(rawValue: value) - ) { - _ = try CmxIrohCustomPrivateAddress(value) - } - } -} - -@Test func customPrivateAddressDecodeRevalidatesFamily() throws { - let valid = Data(#"{"value":"10.0.0.8","family":"ipv4"}"#.utf8) - #expect(try JSONDecoder().decode(CmxIrohCustomPrivateAddress.self, from: valid).value - == "10.0.0.8") - - let tampered = Data(#"{"value":"10.0.0.8","family":"ipv6"}"#.utf8) - #expect(throws: CmxIrohCustomPrivateAddressError.invalidAddress) { - _ = try JSONDecoder().decode(CmxIrohCustomPrivateAddress.self, from: tampered) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohPathHintTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohPathHintTests.swift deleted file mode 100644 index f34c9ac0..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohPathHintTests.swift +++ /dev/null @@ -1,468 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -private let canonicalEndpointID = String(repeating: "a", count: 64) -private let canonicalNetworkProfileID = String(repeating: "b", count: 64) - -private func profile( - _ source: CmxIrohPathHintSource, - _ profileID: String = "default" -) throws -> CmxIrohNetworkProfileKey { - let hex = profileID.utf8.map { String(format: "%02x", $0) }.joined() - let opaqueID = String((hex + String(repeating: "0", count: 64)).prefix(64)) - return try CmxIrohNetworkProfileKey(source: source, profileID: opaqueID) -} - -@Test func irohEndpointIDRequiresCanonicalLowercaseHex() throws { - let identity = try CmxIrohPeerIdentity(endpointID: canonicalEndpointID) - #expect(identity.endpointID == canonicalEndpointID) - for invalid in [ - "", - String(repeating: "a", count: 63), - String(repeating: "a", count: 65), - String(repeating: "A", count: 64), - String(repeating: "g", count: 64), - ] { - #expect(throws: CmxIrohPeerIdentityError.nonCanonicalEndpointID) { - _ = try CmxIrohPeerIdentity(endpointID: invalid) - } - } -} - -@Test func networkProfileIDRequiresOpaqueCanonicalLowercaseHex() throws { - #expect( - (try CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: canonicalNetworkProfileID - )).profileID == canonicalNetworkProfileID - ) - - for invalid in [ - "production", - String(repeating: "a", count: 63), - String(repeating: "a", count: 65), - String(repeating: "A", count: 64), - String(repeating: "g", count: 64), - ] { - #expect(throws: CmxIrohNetworkProfileKeyError.invalidProfileID) { - _ = try CmxIrohNetworkProfileKey(source: .tailscale, profileID: invalid) - } - } -} - -@Test func nativePathHintsCannotAuthorizePrivateOrLocalNetworks() throws { - let now = Date(timeIntervalSince1970: 2_000_000_000) - - for scope in [CmxIrohPathHintPrivacyScope.localNetwork, .privateNetwork] { - #expect(throws: CmxIrohPathHintError.incompatiblePrivacyScope( - source: .native, - scope: scope - )) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .native, - privacyScope: scope, - observedAt: now, - expiresAt: now.addingTimeInterval(60), - networkProfile: try CmxIrohNetworkProfileKey( - source: .native, - profileID: canonicalNetworkProfileID - ) - ) - } - } -} - -@Test func serializedIPv4LinkLocalHintsAreNotDialable() throws { - let now = Date(timeIntervalSince1970: 2_000_000_000) - - #expect(throws: CmxIrohPathHintError.forbiddenDirectAddress) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "169.254.42.7:49152", - source: .lan, - privacyScope: .localNetwork, - observedAt: now, - expiresAt: now.addingTimeInterval(60), - networkProfile: try CmxIrohNetworkProfileKey( - source: .lan, - profileID: canonicalNetworkProfileID - ) - ) - } -} -@Test func attachTicketChoosesFirstSupportedRouteByPriority() throws { - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - id: canonicalEndpointID, - relayHint: "relay-1", - directAddrs: ["192.168.1.20:3478"], - relayURL: "https://relay.example.test" - ), - priority: 0 - ) - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831), - priority: 1 - ) - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: "terminal-1", - macDeviceID: "mac-1", - macDisplayName: "Studio", - routes: [tailscale, iroh], - expiresAt: Date(timeIntervalSince1970: 2_000_000_000) - ) - - #expect(ticket.preferredRoute(supportedKinds: [.tailscale, .iroh]) == iroh) - #expect(ticket.preferredRoute(supportedKinds: [.websocket]) == nil) - #expect(ticket.preferredRoute(supportedKinds: []) == nil) -} - -@Test func irohPeerIdentityIsIndependentFromOrderedProviderPathHints() throws { - let now = Date(timeIntervalSince1970: 2_000_000_000) - let relay = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test", - source: .native, - privacyScope: .publicInternet - ) - let expiredLAN = try CmxIrohPathHint( - kind: .directAddress, - value: "192.168.1.20:49152", - source: .lan, - privacyScope: .localNetwork, - observedAt: now.addingTimeInterval(-60), - expiresAt: now.addingTimeInterval(-1), - networkProfile: profile(.lan, "studio") - ) - let tailscale = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.2:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: now, - expiresAt: now.addingTimeInterval(60), - networkProfile: profile(.tailscale, "production") - ) - let customVPN = try CmxIrohPathHint( - kind: .directAddress, - value: "10.10.0.8:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: now, - expiresAt: now.addingTimeInterval(30), - networkProfile: profile(.customVPN, "corp") - ) - let endpoint = CmxAttachEndpoint.peer( - identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [tailscale, expiredLAN, relay, customVPN] - ) - - let expectedIdentity = try CmxIrohPeerIdentity(endpointID: canonicalEndpointID) - #expect(endpoint.irohPeerIdentity == expectedIdentity) - #expect(tailscale.use == .fallbackOnly) - #expect(expiredLAN.use == .fallbackOnly) - #expect(customVPN.use == .fallbackOnly) - #expect(relay.use == .primary) - let firstPhaseOnly = try #require(endpoint.irohDialPlan( - at: now, - managedRelayURLs: [relay.value] - )) - #expect(firstPhaseOnly.publicPaths == [relay]) - #expect(firstPhaseOnly.privateFallbackPaths.isEmpty) - - let fullPlan = try #require(endpoint.irohDialPlan( - at: now, - managedRelayURLs: [relay.value], - activeNetworkProfiles: [ - profile(.tailscale, "production"), - profile(.customVPN, "corp"), - ] - )) - #expect(fullPlan.publicPaths == [relay]) - #expect(fullPlan.privateFallbackPaths == [tailscale, customVPN]) -} - -@Test func privateProviderHintsRequireMatchingScopeAndExpiry() throws { - let expiry = Date(timeIntervalSince1970: 2_000_000_000) - - #expect(throws: CmxIrohPathHintError.incompatiblePrivacyScope( - source: .tailscale, - scope: .publicInternet - )) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.8.8:49152", - source: .tailscale, - privacyScope: .publicInternet, - expiresAt: expiry - ) - } - #expect(throws: CmxIrohPathHintError.missingPrivateHintObservation) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "192.168.1.20:49152", - source: .lan, - privacyScope: .localNetwork - ) - } - #expect(throws: CmxIrohPathHintError.incompatiblePrivacyScope( - source: .native, - scope: .privateNetwork - )) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .native, - privacyScope: .privateNetwork - ) - } - #expect(throws: CmxIrohPathHintError.missingPrivateHintExpiry) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - networkProfile: profile(.customVPN) - ) - } - #expect(throws: CmxIrohPathHintError.missingPrivateHintNetworkProfile) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry - ) - } - #expect(throws: CmxIrohPathHintError.privateHintTTLExceedsMaximum) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-(CmxIrohPathHint.maximumPrivateHintTTL + 1)), - expiresAt: expiry, - networkProfile: profile(.customVPN) - ) - } - #expect(throws: CmxIrohPathHintError.networkProfileSourceMismatch) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.tailscale) - ) - } -} - -@Test func irohPeerRouteCapsPathHintsAtSixteen() throws { - let hint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test/", - source: .native, - privacyScope: .publicInternet - ) - let maximum = CmxAttachEndpoint.maximumIrohPathHintCount - let endpointID = try CmxIrohPeerIdentity(endpointID: canonicalEndpointID) - - _ = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: endpointID, - pathHints: Array(repeating: hint, count: maximum) - ) - ) - - #expect(throws: CmxAttachRouteError.tooManyPeerPathHints( - actual: maximum + 1, - maximum: maximum - )) { - _ = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: endpointID, - pathHints: Array(repeating: hint, count: maximum + 1) - ) - ) - } -} - -@Test func directPathHintsAcceptOnlyCanonicalIPSocketAddresses() throws { - let expiry = Date(timeIntervalSince1970: 2_000_000_000) - let ipv4 = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN) - ) - let ipv6 = try CmxIrohPathHint( - kind: .directAddress, - value: "[fd7a:115c:a1e0::1]:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.tailscale) - ) - #expect(ipv4.value == "10.0.0.4:49152") - #expect(ipv6.value == "[fd7a:115c:a1e0::1]:49152") - - for malformed in [ - "mac.tailnet.ts.net:49152", - "https://10.0.0.4:49152", - "user@10.0.0.4:49152", - "10.0.0.0/24:49152", - "10.0.0.4", - "10.0.0.4:0", - "010.0.0.4:49152", - "[fe80::1%en0]:49152", - ] { - #expect(throws: CmxIrohPathHintError.invalidDirectAddress) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: malformed, - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN) - ) - } - } -} - -@Test func directPathHintsRejectNonPeerAndMetadataAddresses() throws { - let expiry = Date(timeIntervalSince1970: 2_000_000_000) - for forbidden in [ - "0.0.0.0:49152", - "127.0.0.1:49152", - "224.0.0.1:49152", - "255.255.255.255:49152", - "169.254.169.254:49152", - "[::]:49152", - "[::1]:49152", - "[ff02::1]:49152", - "[fe80::1]:49152", - "[fd00:ec2::254]:49152", - ] { - #expect(throws: CmxIrohPathHintError.forbiddenDirectAddress) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: forbidden, - source: .native, - privacyScope: .localNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.native) - ) - } - } - - #expect(throws: CmxIrohPathHintError.forbiddenDirectAddress) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "169.254.42.7:49152", - source: .lan, - privacyScope: .localNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.lan) - ) - } -} - -@Test func publicDirectPathHintsRequireGloballyRoutableAddresses() throws { - let publicIPv4 = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.8.8:49152", - source: .native, - privacyScope: .publicInternet - ) - let publicIPv6 = try CmxIrohPathHint( - kind: .directAddress, - value: "[2606:4700:4700::1111]:49152", - source: .native, - privacyScope: .publicInternet - ) - #expect(publicIPv4.use == .primary) - #expect(publicIPv6.use == .primary) - - for nonGlobal in [ - "10.0.0.4:49152", - "172.16.0.4:49152", - "192.168.1.4:49152", - "100.64.1.4:49152", - "192.0.2.4:49152", - "198.18.0.4:49152", - "198.51.100.4:49152", - "203.0.113.4:49152", - "[fd7a:115c:a1e0::1]:49152", - "[2001:db8::1]:49152", - "[3fff::1]:49152", - ] { - #expect(throws: CmxIrohPathHintError.nonGlobalPublicDirectAddress) { - _ = try CmxIrohPathHint( - kind: .directAddress, - value: nonGlobal, - source: .native, - privacyScope: .publicInternet - ) - } - } - - let expiry = Date(timeIntervalSince1970: 2_000_000_000) - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN) - ) - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "[fd7a:115c:a1e0::1]:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.tailscale) - ) - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "192.0.2.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN) - ) - _ = try CmxIrohPathHint( - kind: .directAddress, - value: "[2001:db8::1]:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN) - ) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohPrivatePathSynthesizerTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohPrivatePathSynthesizerTests.swift deleted file mode 100644 index 87653239..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohPrivatePathSynthesizerTests.swift +++ /dev/null @@ -1,159 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct CmxIrohPrivatePathSynthesizerTests { - private let now = Date(timeIntervalSince1970: 10_000) - - @Test func addsFallbackOnlyTailscaleHintWithoutChangingPeerIdentity() throws { - let identity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ) - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer(identity: identity, pathHints: []) - ) - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.82.214.112", port: 50906) - ) - - let routes = CmxAttachRoute.addingIrohPrivatePaths( - to: [iroh, tailscale], - observedAt: now - ) - - #expect(routes.map(\.kind) == [.iroh, .tailscale]) - guard case let .peer(resultIdentity, hints) = routes[0].endpoint else { - Issue.record("Expected Iroh peer endpoint") - return - } - #expect(resultIdentity == identity) - let hint = try #require(hints.first) - #expect(hint.value == "100.82.214.112:50906") - #expect(hint.source == .tailscale) - #expect(hint.privacyScope == .privateNetwork) - #expect(hint.use == .fallbackOnly) - #expect(hint.observedAt == now) - #expect(hint.expiresAt == now.addingTimeInterval( - CmxIrohPathHint.maximumPrivateHintTTL - )) - #expect(hint.networkProfile - == CmxIrohNetworkProfileKey.activeTailscaleTunnel) - } - - @Test func ignoresMagicDNSAndGenericPrivateNetworkRoutes() throws { - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ), - pathHints: [] - ) - ) - let magicDNS = try CmxAttachRoute( - id: "magic-dns", - kind: .tailscale, - endpoint: .hostPort(host: "work-mac.tailnet.ts.net", port: 50906) - ) - let genericLAN = try CmxAttachRoute( - id: "lan", - kind: .tailscale, - endpoint: .hostPort(host: "192.168.1.20", port: 50906) - ) - - let routes = CmxAttachRoute.addingIrohPrivatePaths( - to: [iroh, magicDNS, genericLAN], - observedAt: now - ) - - guard case let .peer(_, hints) = routes[0].endpoint else { - Issue.record("Expected Iroh peer endpoint") - return - } - #expect(hints.isEmpty) - } - - @Test func refreshReplacesSameAddressInsteadOfAccumulatingHints() throws { - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "fd7a:115c:a1e0::4b36:d670", port: 50906) - ) - let originalHint = try #require( - tailscale.irohTailscalePathHint(observedAt: now) - ) - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ), - pathHints: [originalHint] - ) - ) - let refreshedAt = now.addingTimeInterval(60) - - let routes = CmxAttachRoute.addingIrohPrivatePaths( - to: [iroh, tailscale], - observedAt: refreshedAt - ) - - guard case let .peer(_, hints) = routes[0].endpoint else { - Issue.record("Expected Iroh peer endpoint") - return - } - #expect(hints.count == 1) - #expect(hints[0].value == "[fd7a:115c:a1e0::4b36:d670]:50906") - #expect(hints[0].observedAt == refreshedAt) - } - - @Test func expiredHintsDoNotConsumeFreshTailscaleCapacity() throws { - let expiredAt = now.addingTimeInterval(-1) - let expiredHint = try CmxIrohPathHint( - kind: .directAddress, - value: "100.82.214.111:50906", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: expiredAt.addingTimeInterval(-60), - expiresAt: expiredAt, - networkProfile: .activeTailscaleTunnel - ) - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity( - endpointID: String(repeating: "c", count: 64) - ), - pathHints: Array( - repeating: expiredHint, - count: CmxAttachEndpoint.maximumIrohPathHintCount - ) - ) - ) - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.82.214.112", port: 50906) - ) - - let routes = CmxAttachRoute.addingIrohPrivatePaths( - to: [iroh, tailscale], - observedAt: now - ) - - guard case let .peer(_, hints) = routes[0].endpoint else { - Issue.record("Expected Iroh peer endpoint") - return - } - #expect(hints.count == 1) - #expect(hints[0].value == "100.82.214.112:50906") - #expect(hints[0].isUsable(at: now)) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohRelayPathHintTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohRelayPathHintTests.swift deleted file mode 100644 index 89b10617..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohRelayPathHintTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Testing -@testable import CMUXMobileCore - -@Test func relayPathHintsAcceptOnlyCredentialFreeRootHTTPSURLs() throws { - let valid = try CmxIrohPathHint( - kind: .relayURL, - value: "https://use1-1.relay.lawrence.cmux.iroh.link/", - source: .native, - privacyScope: .publicInternet - ) - #expect(valid.use == .primary) - - for unsafe in [ - "http://relay.example.test/", - "https://user:secret@relay.example.test/", - "https://relay.example.test/admin", - "https://relay.example.test/?token=secret", - "https://169.254.169.254/", - "https://169.254.42.7/", - "https://10.0.0.1/", - "https://127.0.0.1/", - "https://[::1]/", - "https://[fd7a:115c:a1e0::1]/", - "https://relay.local/", - "https://0177.0.0.1/", - "https://0x7f.0.0.1/", - "https://127.1/", - "https://localhost./", - "https://relay..example.test/", - "https://-relay.example.test/", - "https://relay.example-.test/", - "https://relay.example.123/", - "relay.example.test", - ] { - #expect(throws: CmxIrohPathHintError.unsafeRelayURL) { - _ = try CmxIrohPathHint( - kind: .relayURL, - value: unsafe, - source: .native, - privacyScope: .publicInternet - ) - } - } - - #expect(throws: CmxIrohPathHintError.relayHintRequiresNativePublicSource) { - _ = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test/", - source: .native, - privacyScope: .privateNetwork - ) - } - #expect(throws: CmxIrohPathHintError.relayHintRequiresNativePublicSource) { - _ = try CmxIrohPathHint( - kind: .relayIdentifier, - value: "use1", - source: .tailscale, - privacyScope: .privateNetwork - ) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohSettingsSnapshotTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohSettingsSnapshotTests.swift deleted file mode 100644 index 910e9453..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohSettingsSnapshotTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation -import Testing - -@testable import CMUXMobileCore - -@Suite -struct CmxIrohSettingsSnapshotTests { - @Test - func activeRuntimeStatusPreservesOnlyRedactedPathLabels() { - #expect(CmxIrohSettingsSnapshot.RuntimeStatus( - activePath: .direct - ) == .direct) - #expect(CmxIrohSettingsSnapshot.RuntimeStatus( - activePath: .privateNetwork - ) == .privateNetwork(displayName: "")) - #expect(CmxIrohSettingsSnapshot.RuntimeStatus( - activePath: .managedRelay(provider: "cmux", region: "us-east1") - ) == .relayed(provider: "cmux", region: "us-east1")) - #expect(CmxIrohSettingsSnapshot.RuntimeStatus( - activePath: .customRelay( - displayName: "Office Relay", - provider: "My Network", - region: "Office" - ) - ) == .relayed(provider: "My Network", region: "Office")) - } - - @Test func snapshotCopiesMutableInputsIntoAnImmutableValue() { - var managedRelays = [Self.managedRelay(id: "use1")] - var staleRelayIDs: Set<String> = ["retired"] - let snapshot = CmxIrohSettingsSnapshot( - runtimeStatus: .relayed(provider: "cmux", region: "us-east"), - selectedTransportPath: .managedRelay(provider: "cmux", region: "us-east"), - preference: .managed(["use1"]), - managedRelays: managedRelays, - customRelays: [], - policySource: .server, - policySequence: 42, - staleRelayIDs: staleRelayIDs - ) - - managedRelays.removeAll() - staleRelayIDs.removeAll() - - #expect(snapshot.managedRelays.map(\.id) == ["use1"]) - #expect(snapshot.staleRelayIDs == ["retired"]) - #expect(snapshot.preference == .managed(["use1"])) - #expect(snapshot.selectedTransportPath == .managedRelay( - provider: "cmux", - region: "us-east" - )) - } - - @Test func customRelayProjectionExposesCredentialStateWithoutSecretMaterial() { - let relay = CmxIrohSettingsSnapshot.CustomRelay( - id: "personal", - displayName: "Personal Relay", - provider: "Self-hosted", - region: "Home", - url: "https://relay.example.test", - authMode: .deviceSecret, - credentialState: .configured - ) - let snapshot = CmxIrohSettingsSnapshot( - runtimeStatus: .active, - preference: .custom, - managedRelays: [], - customRelays: [relay], - policySource: .cached - ) - - #expect(snapshot.customRelays == [relay]) - #expect(relay.credentialState == .configured) - #expect(secretBearingLabels(in: snapshot).isEmpty) - } - - @Test func debugTransportProjectionPreservesAllThreeVerificationModes() { - for mode in CmxIrohTransportVerificationMode.allCases { - let snapshot = CmxIrohSettingsSnapshot( - runtimeStatus: .active, - preference: .automatic, - managedRelays: [], - customRelays: [], - policySource: .server, - debugTransportVerificationMode: mode - ) - - #expect(snapshot.debugTransportVerificationMode == mode) - #expect(snapshot.debugRelayOnlyEnabled == (mode == .relayOnly)) - } - } - - @Test func managedPreferenceRequiresOneToSixteenSafeRelayIdentifiers() throws { - #expect(throws: CmxIrohRelayPreferenceDraftError.self) { - try CmxIrohRelayPreferenceDraft.managed([]).validated() - } - #expect(throws: CmxIrohRelayPreferenceDraftError.self) { - try CmxIrohRelayPreferenceDraft.managed(Set((0 ... 16).map { "relay-\($0)" })).validated() - } - #expect(throws: CmxIrohRelayPreferenceDraftError.self) { - try CmxIrohRelayPreferenceDraft.managed(["relay/unsafe"]).validated() - } - - #expect(try CmxIrohRelayPreferenceDraft.managed(["use1-1", "provider.region_2"]).validated() - == .managed(["use1-1", "provider.region_2"])) - #expect(try CmxIrohRelayPreferenceDraft.automatic.validated() == .automatic) - #expect(try CmxIrohRelayPreferenceDraft.custom.validated() == .custom) - } - - private static func managedRelay(id: String) -> CmxIrohSettingsSnapshot.ManagedRelay { - CmxIrohSettingsSnapshot.ManagedRelay( - id: id, - provider: "cmux", - region: "us-east", - url: "https://\(id).relay.example.test", - isSelected: true - ) - } - - private func secretBearingLabels(in value: Any) -> [String] { - let forbiddenFragments = ["secret", "token", "credentialvalue", "authorization"] - var matches: [String] = [] - - func visit(_ value: Any) { - let mirror = Mirror(reflecting: value) - for child in mirror.children { - if let label = child.label { - let normalized = label.lowercased() - if forbiddenFragments.contains(where: normalized.contains) { - matches.append(label) - } - } - visit(child.value) - } - } - - visit(value) - return matches - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohTransportPolicyTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohTransportPolicyTests.swift deleted file mode 100644 index 0ca5cda6..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxIrohTransportPolicyTests.swift +++ /dev/null @@ -1,453 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -private let canonicalEndpointID = String(repeating: "a", count: 64) - -private func profile( - _ source: CmxIrohPathHintSource, - _ profileID: String = "default" -) throws -> CmxIrohNetworkProfileKey { - let hex = profileID.utf8.map { String(format: "%02x", $0) }.joined() - let opaqueID = String((hex + String(repeating: "0", count: 64)).prefix(64)) - return try CmxIrohNetworkProfileKey(source: source, profileID: opaqueID) -} -@Test func dialPlanAdmitsOnlyExactManagedRelayURLsAndNeverLegacyRelayIdentifiers() throws { - let managedURL = "https://use1-1.relay.lawrence.cmux.iroh.link/" - let managed = try CmxIrohPathHint( - kind: .relayURL, - value: managedURL, - source: .native, - privacyScope: .publicInternet - ) - let sameHostDifferentSpelling = try CmxIrohPathHint( - kind: .relayURL, - value: "https://use1-1.relay.lawrence.cmux.iroh.link", - source: .native, - privacyScope: .publicInternet - ) - let attackerControlled = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.attacker.example/", - source: .native, - privacyScope: .publicInternet - ) - let legacyIdentifier = CmxIrohPathHint( - legacyKind: .relayIdentifier, - value: "use1", - privacyScope: .publicInternet - ) - let direct = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.8.8:49152", - source: .native, - privacyScope: .publicInternet - ) - let endpoint = CmxAttachEndpoint.peer( - identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [ - attackerControlled, - legacyIdentifier, - sameHostDifferentSpelling, - managed, - direct, - ] - ) - - let plan = try #require(endpoint.irohDialPlan( - at: Date(), - managedRelayURLs: [managedURL] - )) - #expect(plan.publicPaths == [managed, direct]) - #expect(plan.privateFallbackPaths.isEmpty) - - let noRelayPlan = try #require(endpoint.irohDialPlan( - at: Date(), - managedRelayURLs: [] - )) - #expect(noRelayPlan.publicPaths == [direct]) -} - -@Test func networkProfileIdentityDisambiguatesOverlappingPrivateNetworks() throws { - let expiry = Date(timeIntervalSince1970: 2_000_000_000) - let siteA = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN, "site-a") - ) - let siteB = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.customVPN, "site-b") - ) - let sameNameFromTailscale = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.0.4:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.tailscale, "site-a") - ) - - let expectedSiteAProfile = try profile(.customVPN, "site-a") - let expectedSiteBProfile = try profile(.customVPN, "site-b") - #expect(siteA != siteB) - #expect(siteA.networkProfile == expectedSiteAProfile) - #expect(siteB.networkProfile == expectedSiteBProfile) - #expect(siteA.networkProfile != sameNameFromTailscale.networkProfile) - - let endpoint = CmxAttachEndpoint.peer( - identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [siteA, siteB, sameNameFromTailscale] - ) - let activePlan = try #require(endpoint.irohDialPlan( - at: Date(timeIntervalSince1970: 1_999_999_999), - managedRelayURLs: [], - activeNetworkProfiles: [profile(.customVPN, "site-a")] - )) - #expect(activePlan.privateFallbackPaths == [siteA]) - let inactivePlan = try #require(endpoint.irohDialPlan( - at: Date(timeIntervalSince1970: 1_999_999_999), - managedRelayURLs: [] - )) - #expect(inactivePlan.privateFallbackPaths.isEmpty) -} - -@Test func providerAttributedIrohEndpointRoundTripsIdentityAndHintPolicy() throws { - let expiry = Date( - timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down) + 300 - ) - let endpoint = CmxAttachEndpoint.peer( - identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [ - try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.2:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: expiry.addingTimeInterval(-60), - expiresAt: expiry, - networkProfile: profile(.tailscale, "production") - ), - try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test", - source: .native, - privacyScope: .publicInternet - ), - ] - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let decoded = try decoder.decode( - CmxAttachEndpoint.self, - from: encoder.encode(endpoint) - ) - - #expect(decoded == endpoint) -} - -@Test func irohDisclosureAndPersistencePruneUnsafeHintScopes() throws { - let now = Date() - let publicRelay = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test/", - source: .native, - privacyScope: .publicInternet - ) - let publicDirect = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.8.8:49152", - source: .native, - privacyScope: .publicInternet - ) - let currentPrivate = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.2:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: now, - expiresAt: now.addingTimeInterval(300), - networkProfile: profile(.tailscale, "production") - ) - let expiredPrivate = try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.4:49152", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: now.addingTimeInterval(-120), - expiresAt: now.addingTimeInterval(-60), - networkProfile: profile(.customVPN, "corp") - ) - let route = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [expiredPrivate, currentPrivate, publicDirect, publicRelay] - ) - ) - - let authenticated = try #require(route.disclosed(for: .authenticated, at: now)) - guard case let .peer(_, authenticatedHints) = authenticated.endpoint else { - Issue.record("Expected authenticated Iroh peer route") - return - } - #expect(authenticatedHints == [currentPrivate, publicDirect, publicRelay]) - - let cloud = try #require(route.disclosed(for: .cloudRendezvous, at: now)) - guard case let .peer(_, cloudHints) = cloud.endpoint else { - Issue.record("Expected cloud Iroh peer route") - return - } - #expect(cloudHints == [publicRelay]) - - let backup = try #require(route.disclosed(for: .pairedMacCloudBackup, at: now)) - guard case let .peer(_, backupHints) = backup.endpoint else { - Issue.record("Expected backup Iroh peer route") - return - } - #expect(backupHints == [publicRelay]) - - #expect(route.disclosed(for: .publicStatus, at: now) == nil) - - let pairing = try #require(route.disclosed(for: .pairingQRCode, at: now)) - guard case let .peer(_, pairingHints) = pairing.endpoint else { - Issue.record("Expected pairing Iroh peer route") - return - } - #expect(pairingHints.isEmpty) - - let persisted = try JSONDecoder().decode( - CmxAttachRoute.self, - from: JSONEncoder().encode(authenticated) - ) - guard case let .peer(_, persistedHints) = persisted.endpoint else { - Issue.record("Expected persisted Iroh peer route") - return - } - #expect(persistedHints == [currentPrivate, publicDirect, publicRelay]) -} - -@Test func materiallyFutureDatedPrivateHintsAreNeverAttemptedOrSerialized() throws { - let now = Date() - let networkProfile = try profile(.tailscale, "production") - let toleratedClockSkewHint = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.3:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: now.addingTimeInterval( - CmxIrohPathHint.maximumObservationClockSkew / 2 - ), - expiresAt: now.addingTimeInterval(300), - networkProfile: networkProfile - ) - let futureHint = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.2:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: now.addingTimeInterval(2 * 60 * 60), - expiresAt: now.addingTimeInterval(2 * 60 * 60 + 60), - networkProfile: networkProfile - ) - let route = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [futureHint] - ) - ) - - #expect(toleratedClockSkewHint.isUsable(at: now)) - #expect(!futureHint.isUsable(at: now)) - let dialPlan = try #require(route.endpoint.irohDialPlan( - at: now, - managedRelayURLs: [], - activeNetworkProfiles: [networkProfile] - )) - #expect(dialPlan.privateFallbackPaths.isEmpty) - - let disclosed = try #require(route.disclosed(for: .authenticated, at: now)) - guard case let .peer(_, disclosedHints) = disclosed.endpoint else { - Issue.record("Expected disclosed Iroh peer route") - return - } - #expect(disclosedHints.isEmpty) - - let persisted = try JSONDecoder().decode( - CmxAttachRoute.self, - from: JSONEncoder().encode(disclosed) - ) - guard case let .peer(_, persistedHints) = persisted.endpoint else { - Issue.record("Expected persisted Iroh peer route") - return - } - #expect(persistedHints.isEmpty) -} - -@Test func endpointEncodingIsClockIndependentAndDoesNotDowngradeFreshnessMetadata() throws { - let observedAt = Date(timeIntervalSince1970: 1_000) - let expiresAt = Date(timeIntervalSince1970: 1_060) - let direct = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.8.8:49152", - source: .native, - privacyScope: .publicInternet, - observedAt: observedAt, - expiresAt: expiresAt - ) - let relay = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test/", - source: .native, - privacyScope: .publicInternet, - observedAt: observedAt, - expiresAt: expiresAt - ) - let endpoint = CmxAttachEndpoint.peer( - identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [direct, relay] - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - - let firstEncoding = try encoder.encode(endpoint) - let secondEncoding = try encoder.encode(endpoint) - - #expect(firstEncoding == secondEncoding) - let object = try #require( - try JSONSerialization.jsonObject(with: firstEncoding) as? [String: Any] - ) - #expect((object["path_hints"] as? [[String: Any]])?.count == 2) - // The legacy fields cannot represent freshness metadata. Re-emitting - // either hint there would make an expired path look timeless to an older - // decoder. - #expect(object["direct_addrs"] == nil) - #expect(object["relay_url"] == nil) - #expect(object["relay_hint"] == nil) - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let roundTrippedEndpoint = try decoder.decode(CmxAttachEndpoint.self, from: firstEncoding) - #expect(roundTrippedEndpoint == endpoint) -} - -@Test func publicStatusDisclosesNoAttachRoutes() throws { - let routes = try [ - CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [ - CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test/", - source: .native, - privacyScope: .publicInternet - ), - ] - ) - ), - CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49152) - ), - CmxAttachRoute( - id: "debug", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 49152) - ), - CmxAttachRoute( - id: "websocket", - kind: .websocket, - endpoint: .url("wss://private.example.test/connect?token=secret") - ), - ] - - for route in routes { - #expect(route.disclosed(for: .authenticated, at: Date()) == route) - #expect(route.disclosed(for: .publicStatus, at: Date()) == nil) - } -} - -@Test func legacyFreeFormDirectHintStillDecodesButCannotBeUsedOrPromoted() throws { - let data = Data(""" - { - "id": "iroh", - "kind": "iroh", - "endpoint": { - "type": "peer", - "id": "\(canonicalEndpointID)", - "direct_addrs": ["old-hostname.example:49152"] - } - } - """.utf8) - - let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data) - guard case let .peer(_, pathHints) = route.endpoint else { - Issue.record("Expected an Iroh peer endpoint") - return - } - let hint = try #require(pathHints.first) - #expect(hint.use == .fallbackOnly) - #expect(!hint.isUsable(at: .distantPast)) - - let reencoded = try JSONEncoder().encode(route) - let redecoded = try JSONDecoder().decode(CmxAttachRoute.self, from: reencoded) - guard case let .peer(redecodedIdentity, redecodedHints) = redecoded.endpoint else { - Issue.record("Expected an Iroh peer endpoint") - return - } - #expect(redecodedIdentity.endpointID == canonicalEndpointID) - // A current producer deliberately does not downgrade private fallbacks to - // legacy `direct_addrs`, whose consumers cannot enforce expiry or scope. - #expect(redecodedHints.isEmpty) -} -@Test func legacyUnsafeRelayURLStillDecodesButCannotBeUsedOrReemitted() throws { - let data = Data(""" - { - "id": "iroh", - "kind": "iroh", - "endpoint": { - "type": "peer", - "id": "\(canonicalEndpointID)", - "relay_url": "https://user:secret@relay.example.test/" - } - } - """.utf8) - - let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data) - guard case let .peer(_, pathHints) = route.endpoint else { - Issue.record("Expected an Iroh peer endpoint") - return - } - let hint = try #require(pathHints.first) - #expect(!hint.isSafeForCurrentWireFormat) - #expect(!hint.isUsable(at: .distantPast)) - - let reencoded = try JSONEncoder().encode(route) - let redecoded = try JSONDecoder().decode(CmxAttachRoute.self, from: reencoded) - guard case let .peer(_, redecodedHints) = redecoded.endpoint else { - Issue.record("Expected an Iroh peer endpoint") - return - } - #expect(redecodedHints.isEmpty) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift deleted file mode 100644 index f9e57ebe..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyPrivateNetworkPairingCodeTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct CmxLegacyPrivateNetworkPairingCodeTests { - @Test func encodesTokenlessTailscaleOnlyFullKeyPayload() throws { - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.0.5", port: 58_465), - priority: 10 - ) - let iroh = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ), - pathHints: [] - ), - priority: 0 - ) - let sourceExpiry = Date(timeIntervalSince1970: 1_800_000_000) - let ticket = try CmxAttachTicket( - version: CmxAttachTicket.currentVersion, - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: "Mac", - macUserEmail: "private@example.com", - macUserID: "opaque-user-id", - macPairingCompatibilityVersion: 1, - macAppVersion: "1.0", - macAppBuild: "100", - routes: [iroh, tailscale], - expiresAt: sourceExpiry, - authToken: "secret" - ) - - let encodedURL = try CmxLegacyPrivateNetworkPairingCode().encode(ticket) - let url = try #require(encodedURL) - let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) - let encoded = try #require( - components.queryItems?.first(where: { $0.name == "payload" })?.value - ) - let data = try #require(Self.decodeBase64URL(encoded)) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let decoded = try decoder.decode(CmxAttachTicket.self, from: data) - - #expect(decoded.routes == [tailscale]) - #expect(decoded.authToken == nil) - #expect(decoded.macUserEmail == nil) - #expect(decoded.macUserID == "opaque-user-id") - #expect(try #require(decoded.expiresAt) > sourceExpiry.addingTimeInterval(365 * 24 * 60 * 60)) - } - - @Test func returnsNilWithoutTailscaleRoute() throws { - let ticket = try CmxAttachTicket( - version: CmxAttachTicket.currentVersion, - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: "Mac", - macUserEmail: nil, - macUserID: "opaque-user-id", - routes: [ - try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ), - pathHints: [] - ), - priority: 0 - ), - ], - expiresAt: nil, - authToken: nil - ) - - #expect(try CmxLegacyPrivateNetworkPairingCode().encode(ticket) == nil) - } - - private static func decodeBase64URL(_ value: String) -> Data? { - var normalized = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - normalized += String(repeating: "=", count: (4 - normalized.count % 4) % 4) - return Data(base64Encoded: normalized) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyTailscaleAuthorizationEvidenceTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyTailscaleAuthorizationEvidenceTests.swift deleted file mode 100644 index 841d3607..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLegacyTailscaleAuthorizationEvidenceTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Testing -@testable import CMUXMobileCore - -@Suite struct CmxLegacyTailscaleAuthorizationEvidenceTests { - @Test func canonicalizesUUIDAndNumericIPv6() throws { - let evidence = try CmxLegacyTailscaleAuthorizationEvidence( - macDeviceID: "F4EC647C-9FA1-4B7F-8AB6-4261A8129738", - host: "fd7a:115c:a1e0:0:0:0:0:1234", - port: 58_465 - ) - - #expect(evidence.macDeviceID == "f4ec647c-9fa1-4b7f-8ab6-4261a8129738") - #expect(evidence.host == "fd7a:115c:a1e0::1234") - #expect(evidence.port == 58_465) - #expect(evidence.authorizes( - macDeviceID: "F4EC647C-9FA1-4B7F-8AB6-4261A8129738", - host: "fd7a:115c:a1e0::1234", - port: 58_465 - )) - } - - @Test func rejectsNonPeerInputs() { - #expect(throws: CmxLegacyTailscaleAuthorizationEvidenceError.invalidMacDeviceID) { - _ = try CmxLegacyTailscaleAuthorizationEvidence( - macDeviceID: " mac-1", - host: "100.71.210.41", - port: 58_465 - ) - } - #expect(throws: CmxLegacyTailscaleAuthorizationEvidenceError.invalidHost) { - _ = try CmxLegacyTailscaleAuthorizationEvidence( - macDeviceID: "mac-1", - host: "work-mac.tailnet.ts.net", - port: 58_465 - ) - } - #expect(throws: CmxLegacyTailscaleAuthorizationEvidenceError.invalidHost) { - _ = try CmxLegacyTailscaleAuthorizationEvidence( - macDeviceID: "mac-1", - host: "192.168.1.20", - port: 58_465 - ) - } - #expect(throws: CmxLegacyTailscaleAuthorizationEvidenceError.invalidPort(0)) { - _ = try CmxLegacyTailscaleAuthorizationEvidence( - macDeviceID: "mac-1", - host: "100.71.210.41", - port: 0 - ) - } - } - - @Test func authorizesOnlyExactCanonicalBinding() throws { - let evidence = try CmxLegacyTailscaleAuthorizationEvidence( - macDeviceID: "mac-1", - host: "100.71.210.41", - port: 58_465 - ) - - #expect(evidence.authorizes( - macDeviceID: "mac-1", - host: "100.71.210.41", - port: 58_465 - )) - #expect(!evidence.authorizes( - macDeviceID: "mac-2", - host: "100.71.210.41", - port: 58_465 - )) - #expect(!evidence.authorizes( - macDeviceID: "mac-1", - host: "100.71.210.42", - port: 58_465 - )) - #expect(!evidence.authorizes( - macDeviceID: "mac-1", - host: "100.71.210.41", - port: 58_466 - )) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLoopbackHostTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLoopbackHostTests.swift deleted file mode 100644 index c17b39fa..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxLoopbackHostTests.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -/// Coverage for the shared loopback-host classifier. -@Suite struct CmxLoopbackHostTests { - @Test(arguments: [ - "127.0.0.1", " 127.0.0.1 ", "127.0.0.2", "127.255.255.255", - "localhost", "LocalHost", "dev.localhost", - "localhost.", "dev.localhost.", - "::1", "[::1]", "::ffff:127.0.0.1", "[::ffff:127.0.0.1]", - // Canonical-equivalent spellings: the classifier parses address - // bytes with the resolver's own semantics, so every spelling that - // dials the local machine classifies as loopback. - "0:0:0:0:0:0:0:1", "[0:0:0:0:0:0:0:1]", "[::1%lo0]", - "::ffff:7f00:1", "::127.0.0.1", - "127.1", "127.0.1", "2130706433", "0x7f.0.0.1", "0177.0.0.1", - // 0.0.0.0/8 and :: connect to the local machine too. - "0.0.0.0", "0", "::", - // inet_aton reads "127.0.0" as 127.0.0.0. - "127.0.0", - ]) - func matchesLoopbackSpellings(host: String) { - #expect(CmxLoopbackHost().matches(host)) - } - - @Test(arguments: [ - "100.64.0.5", "128.0.0.1", "126.255.255.255", "10.0.0.1", - "lawrences-mac.tail1234.ts.net", "localhost.example.com", - "fd7a:115c:a1e0::1", "::ffff:100.64.0.5", "127.0.0.0.1", "", - // 128.1 -> 128.0.0.1 and 1681915909 -> 100.64.0.5: legacy numeric - // forms that do NOT land in a self-dialing range stay accepted. - "128.1", "1681915909", - ]) - func rejectsNonLoopbackHosts(host: String) { - #expect(!CmxLoopbackHost().matches(host)) - } - - @Test func classifiesRoutesByKindAndHost() throws { - let devLoopback = try CmxAttachRoute( - id: "debug_loopback", - kind: .debugLoopback, - endpoint: .hostPort(host: "100.64.0.5", port: 58465) - ) - #expect(CmxLoopbackHost().matches(devLoopback)) - - let loopbackTailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "127.0.0.1", port: 58465) - ) - #expect(CmxLoopbackHost().matches(loopbackTailscale)) - - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.0.5", port: 58465) - ) - #expect(!CmxLoopbackHost().matches(tailscale)) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxManualPairingEntryTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxManualPairingEntryTests.swift deleted file mode 100644 index 5733a7bd..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxManualPairingEntryTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -import Testing - -@testable import CMUXMobileCore - -/// Coverage for the manual-entry route selection behind the pairing window's -/// "Copy IP" / "Copy Port" buttons. -@Suite struct CmxManualPairingEntryTests { - private func route( - id: String, - kind: CmxAttachTransportKind = .tailscale, - host: String, - port: Int = 58465, - priority: Int - ) throws -> CmxAttachRoute { - try CmxAttachRoute( - id: id, - kind: kind, - endpoint: .hostPort(host: host, port: port), - priority: priority - ) - } - - @Test func prefersTailscaleIPLiteralOverMagicDNSName() throws { - // The Mac's route resolver emits the MagicDNS name first; the copy - // buttons still surface the numeric IP, which works even when the - // phone's DNS is not pointed at the tailnet. - let entry = CmxManualPairingEntry.best(in: [ - try route(id: "tailscale", host: "lawrences-mac.tail1234.ts.net", priority: 10), - try route(id: "tailscale_2", host: "100.64.0.5", priority: 20), - ]) - #expect(entry == CmxManualPairingEntry(host: "100.64.0.5", port: 58465)) - } - - @Test func fallsBackToDNSNameWhenNoIPLiteralRoute() throws { - let entry = CmxManualPairingEntry.best(in: [ - try route(id: "tailscale", host: "lawrences-mac.tail1234.ts.net", priority: 10), - ]) - #expect(entry == CmxManualPairingEntry(host: "lawrences-mac.tail1234.ts.net", port: 58465)) - } - - @Test func skipsLoopbackRoutesEntirely() throws { - // A DEBUG Mac's dev loopback route must never be offered for manual - // phone entry, same rule as the QR encoder. - let entry = CmxManualPairingEntry.best(in: [ - try route(id: "debug_loopback", kind: .debugLoopback, host: "127.0.0.1", priority: 0), - try route(id: "tailscale", host: "100.64.0.5", priority: 10), - ]) - #expect(entry == CmxManualPairingEntry(host: "100.64.0.5", port: 58465)) - } - - @Test func loopbackOnlyRoutesYieldNothing() throws { - let entry = CmxManualPairingEntry.best(in: [ - try route(id: "debug_loopback", kind: .debugLoopback, host: "127.0.0.1", priority: 0), - // A loopback host hiding under the tailscale kind is still loopback. - try route(id: "tailscale", host: "127.0.0.1", priority: 10), - ]) - #expect(entry == nil) - } - - @Test func ipPreferenceRespectsPriorityOrderAmongLiterals() throws { - let entry = CmxManualPairingEntry.best(in: [ - try route(id: "tailscale_2", host: "100.64.0.9", priority: 20), - try route(id: "tailscale", host: "100.64.0.5", priority: 10), - ]) - #expect(entry == CmxManualPairingEntry(host: "100.64.0.5", port: 58465)) - } - - @Test func emptyRoutesYieldNothing() { - #expect(CmxManualPairingEntry.best(in: []) == nil) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift deleted file mode 100644 index d915fa0b..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRBitmapTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -import CoreGraphics -import Testing - -@testable import CMUXMobileCore - -@Suite struct CmxPairingQRBitmapTests { - private let oneRoutePayload = "cmux-ios://attach?v=2&r=100.64.0.5:52341" - private let twoRoutePayload = - "cmux-ios://attach?v=2&r=lawrences-mac.tail1234.ts.net:52341&r=100.64.0.5:52341" - - /// The full 4-module quiet zone is part of the bitmap itself, so it - /// scales with the code and cannot be cropped away by view layout. Also - /// pins the assumption that the Core Image generator's own margin is - /// exactly 1 module: were it wider or narrower, the module arithmetic - /// in `moduleCount(of:)` would stop matching a legal QR version and - /// `moduleCountMatchesAVersionAtOrBelowSix` would fail. - @Test func bakesFullQuietZoneIntoBitmap() throws { - let image = try #require(CmxPairingQRBitmap().makeImage(payload: oneRoutePayload)) - #expect(image.width == image.height) - - let pixels = try grayLevels(of: image) - let quiet = CmxPairingQRBitmap.quietZoneModules - #expect(image.width > quiet * 2) - for y in 0..<image.height { - for x in 0..<image.width { - let inQuietZone = - x < quiet || y < quiet - || x >= image.width - quiet || y >= image.height - quiet - if inQuietZone { - #expect( - pixels[y * image.width + x] > 245, - "expected white quiet zone at (\(x), \(y))" - ) - } - } - } - } - - /// Every pixel is pure black or pure white: full scanning contrast, - /// independent of app theme, and no interpolation gray (the bitmap is - /// generated at module resolution, never resampled). - @Test func rendersPureBlackOnPureWhiteOnly() throws { - let image = try #require(CmxPairingQRBitmap().makeImage(payload: oneRoutePayload)) - let pixels = try grayLevels(of: image) - #expect(pixels.contains { $0 < 10 }, "expected black modules") - #expect(pixels.contains { $0 > 245 }, "expected white background") - let grayCount = pixels.count { $0 >= 10 && $0 <= 245 } - #expect(grayCount == 0, "expected no mid-gray pixels, found \(grayCount)") - } - - /// At ECC M the representative pairing payloads stay at QR version 6 or - /// lower (41 modules), so each module still renders large in the pairing - /// window. Version v has 17 + 4v modules per side; a side count that - /// breaks that arithmetic means the margin assumption in the renderer is - /// wrong. - @Test func moduleCountMatchesAVersionAtOrBelowSix() throws { - for payload in [oneRoutePayload, twoRoutePayload] { - let image = try #require(CmxPairingQRBitmap().makeImage(payload: payload)) - let modules = image.width - CmxPairingQRBitmap.quietZoneModules * 2 - #expect((modules - 17) % 4 == 0, "\(modules) modules is not a QR version") - #expect(modules >= 21) - #expect(modules <= 41, "payload should stay at version <= 6, got \(modules) modules") - } - } - - /// Renders `image` into an sRGB bitmap and reduces each pixel to its red - /// channel; the QR is grayscale, so one channel carries the module value. - private func grayLevels(of image: CGImage) throws -> [UInt8] { - let width = image.width - let height = image.height - var rgba = [UInt8](repeating: 0, count: width * height * 4) - let colorSpace = try #require(CGColorSpace(name: CGColorSpace.sRGB)) - try rgba.withUnsafeMutableBytes { buffer in - let context = try #require( - CGContext( - data: buffer.baseAddress, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) - ) - context.draw( - image, - in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)) - ) - } - return stride(from: 0, to: rgba.count, by: 4).map { rgba[$0] } - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift deleted file mode 100644 index 6675ff52..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingQRCodeTests.swift +++ /dev/null @@ -1,362 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -/// Coverage for the minimal v2 pairing-QR grammar: bare Tailscale -/// `host:port` routes in the URL query, nothing else. -@Suite struct CmxPairingQRCodeTests { - private func tailscaleRoute( - index: Int, - host: String, - port: Int = 58465 - ) throws -> CmxAttachRoute { - try CmxAttachRoute( - id: index == 0 ? "tailscale" : "tailscale_\(index + 1)", - kind: .tailscale, - endpoint: .hostPort(host: host, port: port), - priority: 10 + index * 10 - ) - } - - private func pairingTicket(routes: [CmxAttachRoute]) throws -> CmxAttachTicket { - // Exactly what the Mac's ticket store mints for the pairing window: - // unscoped, with identity/expiry/token fields the QR must NOT carry. - try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-device-uuid", - macDisplayName: "Lawrence's Mac", - routes: routes, - expiresAt: Date().addingTimeInterval(600), - authToken: "minted-but-never-in-the-qr" - ) - } - - private func components(_ url: String) throws -> URLComponents { - let parsed = try #require(URL(string: url)) - return try #require(URLComponents(url: parsed, resolvingAgainstBaseURL: false)) - } - - private func encodeLegacy(_ ticket: CmxAttachTicket) -> String? { - CmxPairingQRCode().encode( - ticket, - routeDisclosureMode: .legacyPrivateNetworkCompatibility - ) - } - - private func canEncodeLegacy(_ ticket: CmxAttachTicket) -> Bool { - CmxPairingQRCode().canEncode( - ticket, - routeDisclosureMode: .legacyPrivateNetworkCompatibility - ) - } - - @Test func roundTripsSingleRoute() throws { - let ticket = try pairingTicket(routes: [ - try tailscaleRoute(index: 0, host: "100.64.0.5"), - ]) - let url = try #require(encodeLegacy(ticket)) - // The scheme is channel-specific: a release Mac emits cmux-ios, a dev - // Mac emits cmux-ios-dev, so the system camera routes each channel's QR - // to its build. The rest of the URL is identical across channels. - #expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465") - - let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.routes == ticket.routes) - #expect(decoded.workspaceID == "") - #expect(decoded.terminalID == nil) - // Identity, expiry, and token are deliberately absent: the host - // reports identity post-handshake, and nothing in the QR authorizes. - #expect(decoded.macDeviceID == "") - #expect(decoded.macDisplayName == nil) - #expect(decoded.expiresAt == nil) - #expect(decoded.authToken == nil) - } - - @Test func roundTripsMagicDNSPlusIPRoutes() throws { - let routes = [ - try tailscaleRoute(index: 0, host: "lawrences-mac.tail1234.ts.net"), - try tailscaleRoute(index: 1, host: "100.64.0.5"), - ] - let ticket = try pairingTicket(routes: routes) - let url = try #require(encodeLegacy(ticket)) - - let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.routes == routes) - // Synthesized ids and priorities mirror the Mac's route resolver, so - // route preference is preserved without encoding either field. - #expect(decoded.routes.map(\.id) == ["tailscale", "tailscale_2"]) - #expect(decoded.routes.map(\.priority) == [10, 20]) - } - - @Test func roundTripsUserIDAndBuildMetadataWithoutExposingEmail() throws { - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-device-uuid", - macDisplayName: "Lawrence's Mac", - macUserEmail: "Lawrence@Example.com", - macUserID: "user_mac_123", - macPairingCompatibilityVersion: 1, - macAppVersion: "0.64.15", - macAppBuild: "42", - routes: [ - try tailscaleRoute(index: 0, host: "100.64.0.5"), - ], - expiresAt: Date().addingTimeInterval(600), - authToken: "minted-but-never-in-the-qr" - ) - - let url = try #require(encodeLegacy(ticket)) - #expect(url.contains("ub=user_mac_123")) - #expect(!url.contains("Lawrence@Example.com")) - #expect(!url.lowercased().contains("lawrence@example.com")) - #expect(url.contains("pc=1")) - #expect(url.contains("av=0.64.15")) - #expect(url.contains("ab=42")) - - let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.macUserEmail == nil) - #expect(decoded.macUserID == "user_mac_123") - #expect(decoded.macPairingCompatibilityVersion == 1) - #expect(decoded.macAppVersion == "0.64.15") - #expect(decoded.macAppBuild == "42") - #expect(decoded.routes == ticket.routes) - } - - @Test func roundTripsIPv6LiteralThroughRealURLParsing() throws { - let route = try tailscaleRoute(index: 0, host: "fd7a:115c:a1e0::1") - let ticket = try pairingTicket(routes: [route]) - let url = try #require(encodeLegacy(ticket)) - - let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.routes == [route]) - } - - @Test func encodeDropsDevLoopbackRouteFromDebugMacTicket() throws { - // A DEBUG Mac's pairing ticket always carries the dev loopback route. - // The QR must encode only the Tailscale routes: a scanned code - // pointing at 127.0.0.1 makes the phone dial itself (and dialing it - // first added the whole request timeout to scan-to-pair latency). - let loopback = try CmxAttachRoute( - id: "debug_loopback", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 58465), - priority: 0 - ) - let tailscale = try tailscaleRoute(index: 0, host: "100.64.0.5") - let ticket = try pairingTicket(routes: [loopback, tailscale]) - - let url = try #require(encodeLegacy(ticket)) - #expect(url == "\(CmxPairingURLScheme.current)://attach?v=2&r=100.64.0.5:58465") - let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.routes == [tailscale]) - } - - @Test func ticketsOutsideTheMinimalGrammarDoNotEncode() throws { - let tailscale = try tailscaleRoute(index: 0, host: "100.64.0.5") - // Workspace-scoped tickets keep the lossless compact payload. - let scoped = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: nil, - macDeviceID: "mac", - macDisplayName: nil, - routes: [tailscale] - ) - #expect(encodeLegacy(scoped) == nil) - #expect(!canEncodeLegacy(scoped)) - - // Loopback-only dev tickets have nothing a phone could dial. - let loopbackOnly = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac", - macDisplayName: nil, - routes: [ - try CmxAttachRoute( - id: "debug_loopback", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 58465) - ), - ] - ) - #expect(encodeLegacy(loopbackOnly) == nil) - - // Custom route ids cannot be resynthesized by the decoder. - let customID = try pairingTicket(routes: [ - try CmxAttachRoute( - id: "my-route", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.0.5", port: 58465), - priority: 10 - ), - ]) - #expect(encodeLegacy(customID) == nil) - - // A Tailscale-kind route that somehow names a loopback host is a - // weak QR and must not encode. - let loopbackTailscale = try pairingTicket(routes: [ - try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "127.0.0.1", port: 58465), - priority: 10 - ), - ]) - #expect(encodeLegacy(loopbackTailscale) == nil) - - // A non-Tailscale fallback route the bare host:port grammar cannot - // express (an iroh peer) must NOT be silently dropped: the ticket - // keeps the lossless compact payload instead. Only loopback routes, - // which no phone may ever dial, are droppable. - let withIrohFallback = try pairingTicket(routes: [ - tailscale, - try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - id: String(repeating: "d", count: 64), - relayHint: nil, - directAddrs: [], - relayURL: nil - ), - priority: 20 - ), - ]) - #expect(encodeLegacy(withIrohFallback) == nil) - #expect(!canEncodeLegacy(withIrohFallback)) - } - - @Test(arguments: [ - "127.0.0.1", - "127.0.0.2", - "127.255.255.255", - "localhost", - "localhost.", - "sub.localhost", - "LOCALHOST", - "::1", - "0:0:0:0:0:0:0:1", - "::ffff:127.0.0.1", - // Equivalent spellings the resolver dials as loopback: the - // classifier parses bytes, so these cannot slip past as "names". - "127.1", - "2130706433", - "0x7f.0.0.1", - "0.0.0.0", - "::", - "::ffff:7f00:1", - ]) - func decodeRejectsLoopbackHosts(host: String) throws { - let encodedHost = host.contains(":") ? "[\(host)]" : host - let url = "cmux-ios://attach?v=2&r=\(encodedHost):58465" - #expect(throws: MobileSyncPairingPayloadError.loopbackRouteRejected) { - try CmxPairingQRCode().decode(try components(url)) - } - } - - @Test func decodeRejectsLoopbackEvenWhenARealRouteIsPresent() throws { - // Hostile half-and-half codes fail closed, not "dial the good half". - let url = "cmux-ios://attach?v=2&r=100.64.0.5:58465&r=127.0.0.1:58465" - #expect(throws: MobileSyncPairingPayloadError.loopbackRouteRejected) { - try CmxPairingQRCode().decode(try components(url)) - } - } - - @Test func decodeRejectsMalformedRoutes() throws { - let malformed = [ - "cmux-ios://attach?v=2", - "cmux-ios://attach?v=2&r=", - "cmux-ios://attach?v=2&r=hostonly", - "cmux-ios://attach?v=2&r=host:0", - "cmux-ios://attach?v=2&r=host:99999", - "cmux-ios://attach?v=2&r=host:not-a-port", - "cmux-ios://attach?v=2&r=[::1:58465", - ] - for url in malformed { - #expect(throws: (any Error).self, "\(url) should not decode") { - try CmxPairingQRCode().decode(try components(url)) - } - } - } - - @Test func decodeCapsHostileRouteCounts() throws { - let routes = (0..<(CmxPairingQRCode.maximumRouteCount + 1)) - .map { "r=100.64.0.\($0 + 1):58465" } - .joined(separator: "&") - #expect(throws: MobileSyncPairingPayloadError.invalidURL) { - try CmxPairingQRCode().decode(try components("cmux-ios://attach?v=2&\(routes)")) - } - } - - @Test func versionedURLDetectionDistinguishesGrammars() throws { - #expect(CmxPairingQRCode().isPairingCodeURLString("cmux-ios://attach?v=2&r=100.64.0.5:58465")) - #expect(!CmxPairingQRCode().isPairingCodeURLString("cmux-ios://attach?v=1&payload=abc")) - #expect(!CmxPairingQRCode().isPairingCodeURLString("cmux-ios://pair?v=1&payload=abc")) - #expect(!CmxPairingQRCode().isPairingCodeURLString("https://example.com?v=2")) - #expect(!CmxPairingQRCode().isPairingCodeURLString("not a url")) - } - - @Test func decodedTicketStillPairsLongAfterMint() throws { - // The grammar has no expiry field at all, so a code that sat on the - // Mac's screen for 10+ minutes still validates and is never expired. - let url = "cmux-ios://attach?v=2&r=100.64.0.5:58465" - let decoded = try CmxPairingQRCode().decode(try components(url)) - #expect(decoded.expiresAt == nil) - #expect(!decoded.isExpired(at: Date.distantFuture)) - // validate() is structural only; re-running it later cannot fail. - try decoded.validate() - } - - /// Prints the payload-size + QR-version drop from the real encoders, so - /// the win is visible in test output. Binary-mode ECC-M capacities per - /// QR version (ISO/IEC 18004): the smallest version whose capacity fits - /// the payload is what `CIFilter.qrCodeGenerator` emits at level M, the - /// level ``CmxPairingQRBitmap`` renders at (M's redundancy absorbs the - /// glare and off-angle blur of scanning a Mac screen; the payload is - /// small enough that the version stays low anyway). - @Test func reportsPayloadBytesAndQRVersionBeforeAfter() throws { - func qrVersion(forByteCount count: Int) -> Int { - let eccMByteCapacities = [ - 14, 26, 42, 62, 84, 106, 122, 152, 180, 213, - 251, 287, 331, 362, 412, 450, 504, 560, 624, 666, - ] - for (index, capacity) in eccMByteCapacities.enumerated() where count <= capacity { - return index + 1 - } - return eccMByteCapacities.count + 1 - } - - let oneRoute = try pairingTicket(routes: [ - try tailscaleRoute(index: 0, host: "100.64.0.5"), - ]) - let twoRoutes = try pairingTicket(routes: [ - try tailscaleRoute(index: 0, host: "lawrences-mac.tail1234.ts.net"), - try tailscaleRoute(index: 1, host: "100.64.0.5"), - ]) - - for (label, ticket) in [("1-route", oneRoute), ("2-route", twoRoutes)] { - let compactPayload = try CmxAttachTicketCompactCoder().encode( - ticket, - routeDisclosureMode: .legacyPrivateNetworkCompatibility - ) - let base64 = compactPayload.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - let before = "cmux-ios://attach?v=1&payload=\(base64)" - let after = try #require(encodeLegacy(ticket)) - - let beforeBytes = before.utf8.count - let afterBytes = after.utf8.count - print( - "pairing-qr \(label): \(beforeBytes)B/QR v\(qrVersion(forByteCount: beforeBytes)) -> " + - "\(afterBytes)B/QR v\(qrVersion(forByteCount: afterBytes)) (ECC M)" - ) - #expect(afterBytes < beforeBytes) - // The representative 2-route QR stays under 100 bytes / version 6. - #expect(afterBytes < 100) - #expect(qrVersion(forByteCount: afterBytes) <= 6) - } - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingURLSchemeTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingURLSchemeTests.swift deleted file mode 100644 index 4821fd79..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingURLSchemeTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -/// The pairing/attach URL scheme is channel-specific so the system Camera app -/// can never hand a beta/prod QR to a dev build that also claimed the scheme: -/// dev (Debug/tagged) builds register + emit `cmux-ios-dev`, Release (beta + -/// prod) registers + emits `cmux-ios`. Parsers accept every channel's scheme so -/// cross-channel pairing still works from inside the app. -@Suite struct CmxPairingURLSchemeTests { - @Test func developmentBuildsEmitDevScheme() { - #expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: true) == "cmux-ios-dev") - } - - @Test func releaseBuildsEmitReleaseScheme() { - #expect(CmxPairingURLScheme.scheme(isDevelopmentBuild: false) == "cmux-ios") - } - - @Test func currentMatchesThisBuildsCompileChannel() { - // `current` derives from the DEBUG compile flag, so a Debug test run - // emits the dev scheme and a Release test run emits the release scheme. - #if DEBUG - #expect(CmxPairingURLScheme.current == "cmux-ios-dev") - #else - #expect(CmxPairingURLScheme.current == "cmux-ios") - #endif - } - - @Test func parserAcceptsEverySchemeRegardlessOfChannel() { - // Both channels' schemes parse, case-insensitively, so a phone on - // either channel can pair from a QR minted by either channel's Mac. - #expect(CmxPairingURLScheme.isPairingScheme("cmux-ios")) - #expect(CmxPairingURLScheme.isPairingScheme("cmux-ios-dev")) - #expect(CmxPairingURLScheme.isPairingScheme("CMUX-IOS-DEV")) - } - - @Test func parserRejectsForeignSchemes() { - #expect(!CmxPairingURLScheme.isPairingScheme(nil)) - #expect(!CmxPairingURLScheme.isPairingScheme("")) - #expect(!CmxPairingURLScheme.isPairingScheme("https")) - // A different cmux scheme that is not a pairing scheme must not match. - #expect(!CmxPairingURLScheme.isPairingScheme("cmux-ios-staging")) - } - - @Test func prefixCheckAcceptsBothChannelsAndRejectsOthers() { - #expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios://attach?v=2&r=100.64.0.5:58465")) - #expect(CmxPairingURLScheme.hasPairingScheme("cmux-ios-dev://attach?v=2&r=100.64.0.5:58465")) - #expect(CmxPairingURLScheme.hasPairingScheme("CMUX-IOS://attach?v=2")) - #expect(!CmxPairingURLScheme.hasPairingScheme("https://example.com")) - // A bare scheme name without "://" is not a deep link. - #expect(!CmxPairingURLScheme.hasPairingScheme("cmux-ios")) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift deleted file mode 100644 index 8132d967..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct CmxTailscaleStatusPeerResolverTests { - private let resolver = CmxTailscaleStatusPeerResolver() - - @Test func resolvesOneExactDualStackPeerAndPrefersIPv4() throws { - let record = try resolver.resolve( - magicDNSName: " WORK-MAC.TAILNET.TS.NET. ", - statusJSON: statusJSON(peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: ["fd7a:115c:a1e0::1234", "100.71.210.41"] - ), - ]) - ) - - #expect(record.stableID == "node-1") - #expect(record.dnsName == "work-mac.tailnet.ts.net") - #expect(record.addresses.map(\.value) == ["100.71.210.41", "fd7a:115c:a1e0::1234"]) - #expect(record.preferredAddress.value == "100.71.210.41") - #expect(!record.isLocalDevice) - } - - @Test func resolvesIPv6OnlyPeerWithoutFallingBackToGenericPrivateNetworking() throws { - let record = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: statusJSON(peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: ["fd7a:115c:a1e0:0:0:0:0:1234"] - ), - ]) - ) - - #expect(record.preferredAddress.value == "fd7a:115c:a1e0::1234") - #expect(record.preferredAddress.family == .ipv6) - } - - @Test func rejectsNoMatchAndSuffixSubstitution() throws { - let status = try statusJSON(peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: ["100.71.210.41"] - ), - ]) - - #expect(throws: CmxTailscaleStatusPeerResolutionError.peerNotFound) { - _ = try resolver.resolve( - magicDNSName: "other-mac.tailnet.ts.net", - statusJSON: status - ) - } - #expect(throws: CmxTailscaleStatusPeerResolutionError.invalidMagicDNSName) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net.attacker.example", - statusJSON: status - ) - } - } - - @Test func rejectsTwoPeerRecordsClaimingTheSameName() throws { - let status = try statusJSON(peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: ["100.71.210.41"] - ), - peer( - id: "node-2", - dnsName: "work-mac.tailnet.ts.net.", - addresses: ["100.72.1.9"] - ), - ]) - - #expect(throws: CmxTailscaleStatusPeerResolutionError.ambiguousPeer) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: status - ) - } - } - - @Test(arguments: [ - ["100.71.210.41", "203.0.113.10"], - ["100.71.210.41", "192.168.1.20"], - ["100.71.210.41", "fd7a:115c:a1e0::53"], - ["100.100.100.100"], - ["not-an-address"], - ]) - func rejectsMixedPublicPrivateServiceAndMalformedPeerAddresses( - _ addresses: [String] - ) throws { - let status = try statusJSON(peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: addresses - ), - ]) - - #expect(throws: CmxTailscaleStatusPeerResolutionError.invalidPeerAddress) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: status - ) - } - } - - @Test func rejectsTheLocalDeviceForManualPeerAddButCanResolveSelfPublication() throws { - let status = try statusJSON( - local: peer( - id: "self-node", - dnsName: "this-mac.tailnet.ts.net.", - addresses: ["100.70.1.5", "fd7a:115c:a1e0::5"] - ), - peers: [] - ) - - #expect(throws: CmxTailscaleStatusPeerResolutionError.localDeviceNotAllowed) { - _ = try resolver.resolve( - magicDNSName: "this-mac.tailnet.ts.net", - statusJSON: status - ) - } - let local = try resolver.resolve( - magicDNSName: "this-mac.tailnet.ts.net", - statusJSON: status, - allowLocalDevice: true - ) - #expect(local.isLocalDevice) - #expect(local.preferredAddress.value == "100.70.1.5") - } - - @Test func rejectsEmptyAddressesMalformedStatusAndOversizedStatus() throws { - let emptyAddresses = try statusJSON(peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: [] - ), - ]) - #expect(throws: CmxTailscaleStatusPeerResolutionError.missingPeerAddresses) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: emptyAddresses - ) - } - #expect(throws: CmxTailscaleStatusPeerResolutionError.malformedStatus) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: Data("[]".utf8) - ) - } - #expect(throws: CmxTailscaleStatusPeerResolutionError.malformedStatus) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: Data(repeating: 0x20, count: CmxTailscaleStatusPeerResolver.maximumStatusBytes + 1) - ) - } - } - - @Test func rejectsCachedPeerMapWhenTailscaleIsNotRunning() throws { - let status = try statusJSON( - backendState: "Stopped", - peers: [ - peer( - id: "node-1", - dnsName: "work-mac.tailnet.ts.net.", - addresses: ["100.71.210.41"] - ), - ] - ) - - #expect(throws: CmxTailscaleStatusPeerResolutionError.statusNotRunning) { - _ = try resolver.resolve( - magicDNSName: "work-mac.tailnet.ts.net", - statusJSON: status - ) - } - } - - private func statusJSON( - backendState: String = "Running", - local: [String: Any]? = nil, - peers: [[String: Any]] - ) throws -> Data { - var root: [String: Any] = [ - "BackendState": backendState, - "Peer": Dictionary( - uniqueKeysWithValues: peers.enumerated().map { ("peer-\($0.offset)", $0.element) } - ), - ] - if let local { - root["Self"] = local - } - return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) - } - - private func peer( - id: String, - dnsName: String, - addresses: [String] - ) -> [String: Any] { - [ - "ID": id, - "DNSName": dnsName, - "TailscaleIPs": addresses, - ] - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTransportTestDoubles.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTransportTestDoubles.swift deleted file mode 100644 index 7f21a9a5..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTransportTestDoubles.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -@testable import CMUXMobileCore - -struct TaggedTransportFactory: CmxByteTransportFactory { - var tag: String - - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - TaggedTransport(tag: tag, route: route) - } -} - -struct RequestTaggedTransportFactory: CmxByteTransportFactory { - func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - TaggedTransport(tag: "route-only", route: route) - } - - func makeTransport( - for request: CmxByteTransportRequest - ) throws -> any CmxByteTransport { - let mode = request.authorizationMode == .transportAdmission ? "admission" : "stack" - return TaggedTransport( - tag: "\(request.expectedPeerDeviceID ?? "missing"):\(mode)", - route: request.route - ) - } -} - -struct TaggedTransport: CmxByteTransport { - var tag: String - var route: CmxAttachRoute - - func connect() async throws {} - - func receive() async throws -> Data? { - nil - } - - func send(_ data: Data) async throws {} - - func close() async {} -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTransportTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTransportTests.swift deleted file mode 100644 index 23c2bd75..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTransportTests.swift +++ /dev/null @@ -1,481 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -private let canonicalEndpointID = String(repeating: "a", count: 64) - -private func profile( - _ source: CmxIrohPathHintSource, - _ profileID: String = "default" -) throws -> CmxIrohNetworkProfileKey { - let hex = profileID.utf8.map { String(format: "%02x", $0) }.joined() - let opaqueID = String((hex + String(repeating: "0", count: 64)).prefix(64)) - return try CmxIrohNetworkProfileKey(source: source, profileID: opaqueID) -} -@Test func attachTicketUsesDebugLoopbackBeforeTailscaleWhenBothAreSupported() throws { - let loopback = try CmxAttachRoute( - id: "debug", - kind: .debugLoopback, - endpoint: .hostPort(host: "127.0.0.1", port: 49831), - priority: 0 - ) - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831), - priority: 10 - ) - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: "terminal-1", - macDeviceID: "mac-1", - macDisplayName: "Studio", - routes: [tailscale, loopback], - expiresAt: Date(timeIntervalSince1970: 2_000_000_000) - ) - - #expect(ticket.preferredRoute(supportedKinds: [.tailscale, .debugLoopback]) == loopback) - #expect(ticket.preferredRoute(supportedKinds: [.tailscale]) == tailscale) -} - -@Test func attachTicketRoundTripsAllEndpointKinds() throws { - let privateHintExpiry = Date( - timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.down) + 300 - ) - let routes = try [ - CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ), - CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer( - identity: try CmxIrohPeerIdentity(endpointID: canonicalEndpointID), - pathHints: [ - try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.1.2:49152", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: privateHintExpiry.addingTimeInterval(-60), - expiresAt: privateHintExpiry, - networkProfile: profile(.tailscale, "production") - ), - try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.test", - source: .native, - privacyScope: .publicInternet - ), - ] - ) - ), - CmxAttachRoute( - id: "websocket", - kind: .websocket, - endpoint: .url("wss://cmux.example.test/terminal") - ), - ] - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: routes, - expiresAt: Date(timeIntervalSince1970: 2_000_000_000), - authToken: "ticket-secret" - ) - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - let data = try encoder.encode(ticket) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let decoded = try decoder.decode(CmxAttachTicket.self, from: data) - - #expect(decoded == ticket) -} - -@Test func attachTicketRejectsEmptyAuthToken() throws { - let route = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ) - - #expect(throws: CmxAttachTicketError.emptyAuthToken) { - _ = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [route], - expiresAt: Date(timeIntervalSince1970: 2_000_000_000), - authToken: " " - ) - } -} - -@Test func attachTicketConstructsWithPastExpiryAndReportsExpired() throws { - // Expiry is data for token consumers, not a structural validity gate: a - // stale ticket still constructs (a QR scanned long after it was shown must - // keep pairing), and `isExpired(at:)` reports its token lifetime. - let route = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ) - - let ticket = try CmxAttachTicket( - workspaceID: "workspace-1", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [route], - expiresAt: Date(timeIntervalSince1970: 1_000) - ) - #expect(ticket.isExpired(at: Date(timeIntervalSince1970: 2_000))) - #expect(!ticket.isExpired(at: Date(timeIntervalSince1970: 500))) -} - -@Test func attachTicketWithoutExpiryNeverExpires() throws { - let route = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ) - - let ticket = try CmxAttachTicket( - workspaceID: "", - terminalID: nil, - macDeviceID: "mac-1", - macDisplayName: nil, - routes: [route] - ) - #expect(ticket.expiresAt == nil) - #expect(!ticket.isExpired(at: .distantFuture)) -} - -@Test func attachRouteDecodesIrohAddressHintsFromExperimentRouteJSON() throws { - let data = Data(""" - { - "id": "iroh", - "kind": "iroh", - "endpoint": { - "type": "peer", - "id": "\(canonicalEndpointID)", - "direct_addrs": ["192.168.1.20:49152", "100.64.1.2:49152"], - "relay_url": "https://relay.example.test" - }, - "priority": 20 - } - """.utf8) - - let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data) - - #expect(route.id == "iroh") - #expect(route.kind == .iroh) - #expect(route.priority == 20) - guard case let .peer(identity, pathHints) = route.endpoint else { - Issue.record("Expected an Iroh peer endpoint") - return - } - #expect(identity.endpointID == canonicalEndpointID) - #expect(pathHints.filter { $0.kind == .relayIdentifier }.isEmpty) - #expect(pathHints.filter { $0.kind == .directAddress }.map(\.value) == [ - "192.168.1.20:49152", - "100.64.1.2:49152", - ]) - #expect(pathHints.first { $0.kind == .relayURL }?.value == "https://relay.example.test") - #expect(pathHints.filter { $0.kind == .directAddress }.allSatisfy { - $0.use == .fallbackOnly && !$0.isUsable(at: .distantPast) - }) -} - -@Test func attachRouteDecodesLegacyPeerRouteWithoutIrohAddressHints() throws { - let data = Data(""" - { - "id": "iroh", - "kind": "iroh", - "endpoint": { - "type": "peer", - "id": "\(canonicalEndpointID)", - "relay_hint": "legacy-relay" - }, - "priority": 20 - } - """.utf8) - - let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data) - - guard case let .peer(identity, pathHints) = route.endpoint else { - Issue.record("Expected an Iroh peer endpoint") - return - } - #expect(identity.endpointID == canonicalEndpointID) - #expect(pathHints.first { $0.kind == .relayIdentifier }?.value == "legacy-relay") - #expect(pathHints.filter { $0.kind == .directAddress }.isEmpty) - #expect(pathHints.filter { $0.kind == .relayURL }.isEmpty) -} - -@Test func attachRouteDecoderDefaultsMissingPriorityToZero() throws { - let data = Data(""" - { - "id": "tailscale", - "kind": "tailscale", - "endpoint": { - "type": "host_port", - "host": "100.64.1.2", - "port": 49831 - } - } - """.utf8) - - let route = try JSONDecoder().decode(CmxAttachRoute.self, from: data) - - #expect(route.kind == .tailscale) - #expect(route.endpoint == .hostPort(host: "100.64.1.2", port: 49831)) - #expect(route.priority == 0) -} - -@Test func attachRouteRejectsMismatchedEndpointKind() throws { - #expect(throws: CmxAttachRouteError.endpointMismatch( - kind: .iroh, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - )) { - _ = try CmxAttachRoute( - id: "bad", - kind: .iroh, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ) - } -} - -@Test func attachRouteDecoderRejectsMismatchedEndpointKind() throws { - let data = Data(""" - { - "id": "bad", - "kind": "iroh", - "endpoint": { - "type": "host_port", - "host": "100.64.1.2", - "port": 49831 - }, - "priority": 0 - } - """.utf8) - - #expect(throws: CmxAttachRouteError.endpointMismatch( - kind: .iroh, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - )) { - _ = try JSONDecoder().decode(CmxAttachRoute.self, from: data) - } -} - -@Test func attachTicketDecoderRejectsNoRoutes() throws { - let data = Data(""" - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [], - "expiresAt": "2033-05-18T03:33:20Z" - } - """.utf8) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - #expect(throws: CmxAttachTicketError.noRoutes) { - _ = try decoder.decode(CmxAttachTicket.self, from: data) - } -} - -@Test func attachTicketDecoderAcceptsExpiredTicketAndPreservesExpiry() throws { - // A legacy full-key QR scanned long after it was shown must keep - // decoding; expiry is preserved as data for token consumers, not - // enforced at decode time. - let data = Data(""" - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [ - { - "id": "tailscale", - "kind": "tailscale", - "endpoint": { - "type": "host_port", - "host": "100.64.1.2", - "port": 49831 - }, - "priority": 0 - } - ], - "expiresAt": "2001-01-01T00:00:00Z" - } - """.utf8) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let ticket = try decoder.decode(CmxAttachTicket.self, from: data) - #expect(ticket.expiresAt == Date(timeIntervalSince1970: 978_307_200)) - #expect(ticket.isExpired(at: Date())) -} - -@Test func attachTicketDecoderAcceptsMissingExpiry() throws { - let data = Data(""" - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [ - { - "id": "tailscale", - "kind": "tailscale", - "endpoint": { - "type": "host_port", - "host": "100.64.1.2", - "port": 49831 - }, - "priority": 0 - } - ] - } - """.utf8) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let ticket = try decoder.decode(CmxAttachTicket.self, from: data) - #expect(ticket.expiresAt == nil) - #expect(!ticket.isExpired(at: Date())) -} - -@Test func attachTicketDecoderRejectsInvalidNestedRoute() throws { - let data = Data(""" - { - "version": 1, - "workspaceID": "workspace-1", - "terminalID": null, - "macDeviceID": "mac-1", - "macDisplayName": null, - "routes": [ - { - "id": "bad", - "kind": "iroh", - "endpoint": { - "type": "host_port", - "host": "100.64.1.2", - "port": 49831 - }, - "priority": 0 - } - ], - "expiresAt": "2033-05-18T03:33:20Z" - } - """.utf8) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - #expect(throws: CmxAttachRouteError.endpointMismatch( - kind: .iroh, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - )) { - _ = try decoder.decode(CmxAttachTicket.self, from: data) - } -} - -@Test func routeTransportFactoryDispatchesByRouteKind() throws { - let factory = try CmxRouteTransportFactory([ - CmxRouteTransportFactoryRegistration( - kind: .tailscale, - factory: TaggedTransportFactory(tag: "tailscale-tcp") - ), - CmxRouteTransportFactoryRegistration( - kind: .iroh, - factory: TaggedTransportFactory(tag: "iroh-peer") - ), - ]) - let tailscaleRoute = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.1.2", port: 49831) - ) - let irohRoute = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer(id: canonicalEndpointID, relayHint: nil, directAddrs: [], relayURL: nil) - ) - - let tailscaleTransport = try factory.makeTransport(for: tailscaleRoute) - let irohTransport = try factory.makeTransport(for: irohRoute) - - #expect(factory.supportedKinds == [.tailscale, .iroh]) - #expect((tailscaleTransport as? TaggedTransport)?.tag == "tailscale-tcp") - #expect((irohTransport as? TaggedTransport)?.tag == "iroh-peer") -} - -@Test func routeTransportFactoryRejectsDuplicateRegistrations() throws { - #expect(throws: CmxRouteTransportFactoryError.duplicateRouteKind(.tailscale)) { - _ = try CmxRouteTransportFactory([ - CmxRouteTransportFactoryRegistration( - kind: .tailscale, - factory: TaggedTransportFactory(tag: "first") - ), - CmxRouteTransportFactoryRegistration( - kind: .tailscale, - factory: TaggedTransportFactory(tag: "second") - ), - ]) - } -} - -@Test func routeTransportFactoryPreservesPeerIntentForRequestAwareTransports() throws { - let factory = try CmxRouteTransportFactory([ - CmxRouteTransportFactoryRegistration( - kind: .iroh, - factory: RequestTaggedTransportFactory() - ), - ]) - let route = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer(id: canonicalEndpointID, relayHint: nil, directAddrs: [], relayURL: nil) - ) - let request = CmxByteTransportRequest( - route: route, - expectedPeerDeviceID: "mac-device-a", - authorizationMode: .transportAdmission - ) - - let transport = try factory.makeTransport(for: request) - - #expect((transport as? TaggedTransport)?.tag == "mac-device-a:admission") -} - -@Test func routeTransportFactoryRejectsUnsupportedRouteKind() throws { - let factory = try CmxRouteTransportFactory([ - CmxRouteTransportFactoryRegistration( - kind: .tailscale, - factory: TaggedTransportFactory(tag: "tailscale-tcp") - ), - ]) - let route = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer(id: canonicalEndpointID, relayHint: nil, directAddrs: [], relayURL: nil) - ) - - #expect(throws: CmxRouteTransportFactoryError.unsupportedRouteKind(.iroh)) { - _ = try factory.makeTransport(for: route) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ComposerDockReducerTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ComposerDockReducerTests.swift deleted file mode 100644 index 8b0131fb..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ComposerDockReducerTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Testing -@testable import CMUXMobileCore - -/// Verifies the pure compose-button decision that keeps the iOS terminal composer -/// coherent across the compose → hide → reveal → compose cycle a user hit on device: -/// the draft must never be dismissed by the compose button while the composer is -/// still logically presented but visually suppressed or unfocused. -@Suite struct ComposerDockReducerTests { - /// A fresh open: nothing presented, so the button opens the composer. - @Test func composeButtonOpensWhenNothingPresented() { - let state = ComposerDockState( - chromeHidden: false, - composerPresented: false, - fieldFocused: false, - keyboardUp: false - ) - #expect(state.intentForComposeButtonTap() == .openComposer) - } - - /// A genuinely visible, focused composer: the button closes it. This is the ONLY - /// path that dismisses the composer from the button. - @Test func composeButtonClosesWhenVisibleAndFocused() { - let state = ComposerDockState( - chromeHidden: false, - composerPresented: true, - fieldFocused: true, - keyboardUp: true - ) - #expect(state.intentForComposeButtonTap() == .closeComposer) - } - - /// Composer presented but the HIDE button suppressed the chrome: the button must - /// REVEAL + focus, not close (closing here is what lost the draft on device). - @Test func composeButtonRevealsWhenPresentedButChromeHidden() { - let state = ComposerDockState( - chromeHidden: true, - composerPresented: true, - fieldFocused: false, - keyboardUp: false - ) - #expect(state.intentForComposeButtonTap() == .revealAndFocusComposer) - } - - /// The exact device-trace state: after a reveal-from-hide the chrome is back and - /// the composer is presented, but the terminal proxy (not the field) holds first - /// responder. The button must REVEAL + focus the field, not toggle the composer - /// closed. - @Test func composeButtonRefocusesWhenPresentedAndVisibleButFieldUnfocused() { - let state = ComposerDockState( - chromeHidden: false, - composerPresented: true, - fieldFocused: false, - keyboardUp: false - ) - #expect(state.intentForComposeButtonTap() == .revealAndFocusComposer) - } - - /// End-to-end: replay the reported compose → hide → reveal → compose sequence as - /// pure state transitions and assert the final compose tap ends in a presented, - /// focused composer (draft preserved), never a close. - @Test func composeHideRevealComposeKeepsComposerPresentedAndFocused() { - // 1. Compose: open from nothing. Intent = open. - var state = ComposerDockState( - chromeHidden: false, - composerPresented: false, - fieldFocused: false, - keyboardUp: false - ) - #expect(state.intentForComposeButtonTap() == .openComposer) - - // After open: composer presented, field focused, keyboard up. - state.composerPresented = true - state.fieldFocused = true - state.keyboardUp = true - - // 2. Hide: chrome suppressed, keyboard dropped, field loses first responder. - // `composerPresented` stays true (HIDE never dismisses), so the draft lives. - state.chromeHidden = true - state.fieldFocused = false - state.keyboardUp = false - #expect(state.composerPresented) - - // 3. Reveal (terminal tap): chrome returns but the terminal proxy takes first - // responder, so the composer field is NOT focused yet. - state.chromeHidden = false - state.fieldFocused = false - #expect(state.composerPresented) - - // 4. Compose again: presented + visible + field-unfocused → reveal/refocus, - // NOT close. The composer (and its draft) stays. - #expect(state.intentForComposeButtonTap() == .revealAndFocusComposer) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ConnectionOutageThrottleTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ConnectionOutageThrottleTests.swift deleted file mode 100644 index 2f032927..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ConnectionOutageThrottleTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Testing - -@testable import CMUXMobileCore - -@Suite struct ConnectionOutageThrottleTests { - @Test func firstDropEmitsLost() { - var throttle = ConnectionOutageThrottle() - let signal = throttle.record(transition: .init(wasConnected: true, isConnected: false)) - #expect(signal == .lost) - #expect(throttle.outageOpen) - } - - @Test func flappingDuringOutageEmitsOnce() { - var throttle = ConnectionOutageThrottle() - #expect(throttle.record(transition: .init(wasConnected: true, isConnected: false)) == .lost) - // A repeated disconnected→disconnected churn must not re-emit. - #expect(throttle.record(transition: .init(wasConnected: false, isConnected: false)) == nil) - // Another connected→disconnected edge while the outage is still open: no - // second lost. - #expect(throttle.record(transition: .init(wasConnected: true, isConnected: false)) == nil) - #expect(throttle.outageOpen) - } - - @Test func recoveryAfterOutageEmitsRecovered() { - var throttle = ConnectionOutageThrottle() - _ = throttle.record(transition: .init(wasConnected: true, isConnected: false)) - let signal = throttle.record(transition: .init(wasConnected: false, isConnected: true)) - #expect(signal == .recovered) - #expect(!throttle.outageOpen) - } - - @Test func recoveryWithoutOpenOutageIsNoop() { - var throttle = ConnectionOutageThrottle() - let signal = throttle.record(transition: .init(wasConnected: false, isConnected: true)) - #expect(signal == nil) - #expect(!throttle.outageOpen) - } - - @Test func fullOutageCycleEmitsExactlyOneLostOneRecovered() { - var throttle = ConnectionOutageThrottle() - var signals: [ConnectionOutageThrottle.Signal] = [] - let transitions: [ConnectionOutageThrottle.Transition] = [ - .init(wasConnected: true, isConnected: false), // lost - .init(wasConnected: false, isConnected: false), // flap - .init(wasConnected: false, isConnected: true), // recovered - .init(wasConnected: true, isConnected: true), // steady - .init(wasConnected: true, isConnected: false), // lost again (new outage) - .init(wasConnected: false, isConnected: true), // recovered again - ] - for transition in transitions { - if let signal = throttle.record(transition: transition) { signals.append(signal) } - } - #expect(signals == [.lost, .recovered, .lost, .recovered]) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift deleted file mode 100644 index 72c6ed44..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift +++ /dev/null @@ -1,562 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct DiagnosticLogTests { - private enum ClassifiedTestError: Error, DiagnosticFailureProviding { - case denied - - var diagnosticFailureKind: DiagnosticFailureKind { .admissionDenied } - } - - /// Await the log's drain task until its ring reports `expected` events, so a - /// test can assert on a deterministic post-drain state without sleeping. The - /// drain task runs on the cooperative pool; `Task.yield()` lets it advance. - /// Bounded so a regression that never drains fails instead of hanging. - /// Await the drain task processing at least `expected` total events, so a - /// test can assert on a deterministic post-drain state without sleeping. - /// ``DiagnosticLog/processedCount()`` only grows (eviction does not lower - /// it), so it is a stable barrier even when the ring is at capacity. The - /// drain task runs on the cooperative pool; `Task.yield()` lets it advance. - /// Bounded so a regression that never drains fails instead of hanging. - private func waitForProcessed(_ log: DiagnosticLog, _ expected: Int) async { - for _ in 0..<1_000_000 { - if await log.processedCount() >= expected { return } - await Task.yield() - } - } - - /// Record one event and await it draining into the ring, so the next record - /// never overflows the stream buffer. Draining each event before recording - /// the next means what survives is governed only by the ring's eviction - /// (deterministic) and never by `.bufferingNewest`'s pending-drop policy - /// (timing-dependent). - private func recordAndDrain( - _ log: DiagnosticLog, - _ event: DiagnosticEvent, - processedAfter: Int - ) async { - log.record(event) - await waitForProcessed(log, processedAfter) - } - - @Test func recordThenExportRoundTrips() async { - let log = DiagnosticLog( - capacity: 16, - buildStamp: "cmux DEV test", - anchorWallNanos: 1_700_000_000_000_000_000, - anchorMonotonicNanos: 500 - ) - log.record(DiagnosticEvent(code: .connect, tNanos: 1_000)) - log.record(DiagnosticEvent(code: .pairOk, tNanos: 2_000, ms: 250)) - log.record(DiagnosticEvent(code: .inputSeqBehind, tNanos: 3_000, surface: 7, a: 10, b: 20)) - await waitForProcessed(log, 3) - - let blob = await log.export() - let text = String(decoding: blob, as: UTF8.self) - let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - - // Header: version, anchors, count, build stamp. - #expect(lines[0].hasPrefix("cmuxdiag v1")) - #expect(lines[0].contains("anchorWallNs=1700000000000000000")) - #expect(lines[0].contains("anchorMonoNs=500")) - #expect(lines[0].contains("count=3")) - #expect(lines[0].contains("build=cmux DEV test")) - - // One compact row per event: tNanos,code,surface,ms,a,b,c (absent = empty). - #expect(lines[1] == "1000,1,,,,,") - #expect(lines[2] == "2000,2,,250,,,") - #expect(lines[3] == "3000,7,7,,10,20,") - } - - @Test func duplicateSelectedPathNotificationsDoNotExportFalseChanges() async { - let log = DiagnosticLog(capacity: 16) - log.record(DiagnosticEvent( - code: .selectedPathChanged, - tNanos: 1_000, - a: DiagnosticPathKind.relay.rawValue - )) - log.record(DiagnosticEvent( - code: .transportSessionLifecycle, - tNanos: 2_000, - a: DiagnosticSessionLifecycleKind.established.rawValue, - b: Int(CmxTransportSessionPurpose.foregroundControl.rawValue), - c: 1 - )) - log.record(DiagnosticEvent( - code: .selectedPathChanged, - tNanos: 3_000, - a: DiagnosticPathKind.relay.rawValue - )) - log.record(DiagnosticEvent( - code: .selectedPathChanged, - tNanos: 4_000, - a: DiagnosticPathKind.privateNetwork.rawValue - )) - log.record(DiagnosticEvent( - code: .selectedPathChanged, - tNanos: 5_000, - a: DiagnosticPathKind.privateNetwork.rawValue - )) - await waitForProcessed(log, 5) - - let report = await log.snapshot() - #expect(report.events.map(\.code) == [ - .selectedPathChanged, - .transportSessionLifecycle, - .selectedPathChanged, - ]) - #expect(report.events.compactMap(\.diagnosticPathKind) == [ - .relay, - .privateNetwork, - ]) - #expect(report.events[1].diagnosticSessionLifecycleKind == .established) - } - - @Test func ringEvictionDropsOldest() async { - let log = DiagnosticLog(capacity: 3) - // Drain each event before recording the next so eviction is governed - // purely by the ring (not by the stream's bufferingNewest drop policy). - for i in 0..<6 { - await recordAndDrain( - log, - DiagnosticEvent(code: .connect, tNanos: UInt64(i)), - processedAfter: i + 1 - ) - } - #expect(await log.count() == 3) - - let text = String(decoding: await log.export(), as: UTF8.self) - let rows = text - .split(separator: "\n", omittingEmptySubsequences: false) - .dropFirst() - .filter { !$0.isEmpty } - .map(String.init) - #expect(rows.count == 3) - // Oldest (tNanos 0,1,2) evicted; newest (3,4,5) retained, in order. - #expect(rows[0].hasPrefix("3,")) - #expect(rows[1].hasPrefix("4,")) - #expect(rows[2].hasPrefix("5,")) - } - - @Test func recordIsNonBlockingUnderBurst() async { - // A burst far larger than capacity must not block the recorder: every - // `record` returns synchronously (no await), and the ring stays bounded - // by capacity once the drain settles. `.bufferingNewest` drops the - // oldest *pending* events, so the final ring is bounded, not exact. - let capacity = 64 - let log = DiagnosticLog(capacity: capacity) - let burst = 50_000 - for i in 0..<burst { - log.record(DiagnosticEvent(code: .renderGridLag, tNanos: UInt64(i), ms: 1)) - } - // The recorder never suspended; we reach here immediately. Let the drain - // settle and confirm the ring never exceeds capacity. - await waitForProcessed(log, 1) - let count = await log.count() - #expect(count >= 1) - #expect(count <= capacity) - } - - @Test func circularBufferWrapsAndPreservesChronologicalOrder() async { - // Drive the O(1) ring past several full wrap cycles and confirm export - // still yields exactly the newest `capacity` events in record order, - // proving the head/offset arithmetic is correct across the wrap boundary. - let capacity = 4 - let log = DiagnosticLog(capacity: capacity) - let total = 13 // 3 full cycles + 1, so head wraps and lands mid-array - for i in 0..<total { - await recordAndDrain( - log, - DiagnosticEvent(code: .connect, tNanos: UInt64(i)), - processedAfter: i + 1 - ) - } - #expect(await log.count() == capacity) - - let text = String(decoding: await log.export(), as: UTF8.self) - let rows = text - .split(separator: "\n", omittingEmptySubsequences: false) - .dropFirst() - .filter { !$0.isEmpty } - .map(String.init) - #expect(rows.count == capacity) - // Newest `capacity` events are tNanos 9,10,11,12, in order. - #expect(rows[0].hasPrefix("9,")) - #expect(rows[1].hasPrefix("10,")) - #expect(rows[2].hasPrefix("11,")) - #expect(rows[3].hasPrefix("12,")) - } - - @Test func exportOnEmptyLogHasHeaderOnly() async { - let log = DiagnosticLog(capacity: 8) - let text = String(decoding: await log.export(), as: UTF8.self) - let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - #expect(lines[0].hasPrefix("cmuxdiag v1")) - #expect(lines[0].contains("count=0")) - // No build stamp segment when empty default was used. - #expect(!lines[0].contains("build=")) - // Nothing after the header but the trailing newline split. - #expect(lines.filter { !$0.isEmpty }.count == 1) - } - - @Test func transportDiagnosticCodesAreStableAndAppendOnly() { - #expect(DiagnosticEventCode.transportDialStarted.rawValue == 25) - #expect(DiagnosticEventCode.transportDialConnected.rawValue == 26) - #expect(DiagnosticEventCode.transportDialFailed.rawValue == 27) - #expect(DiagnosticEventCode.hostAuthenticated.rawValue == 28) - #expect(DiagnosticEventCode.rpcReady.rawValue == 29) - #expect(DiagnosticEventCode.recoveryStarted.rawValue == 30) - #expect(DiagnosticEventCode.recoverySucceeded.rawValue == 31) - #expect(DiagnosticEventCode.recoveryFailed.rawValue == 32) - #expect(DiagnosticEventCode.endpointStarting.rawValue == 33) - #expect(DiagnosticEventCode.endpointActive.rawValue == 34) - #expect(DiagnosticEventCode.endpointStopped.rawValue == 35) - #expect(DiagnosticEventCode.endpointFailed.rawValue == 36) - #expect(DiagnosticEventCode.relayPolicyRefreshStarted.rawValue == 37) - #expect(DiagnosticEventCode.relayPolicyRefreshSucceeded.rawValue == 38) - #expect(DiagnosticEventCode.relayPolicyRefreshFailed.rawValue == 39) - #expect(DiagnosticEventCode.selectedPathChanged.rawValue == 40) - #expect(DiagnosticEventCode.sessionClosed.rawValue == 41) - #expect(DiagnosticEventCode.routeUnavailable.rawValue == 42) - #expect(DiagnosticEventCode.retryScheduled.rawValue == 43) - #expect(DiagnosticEventCode.discoveryStarted.rawValue == 44) - #expect(DiagnosticEventCode.discoverySucceeded.rawValue == 45) - #expect(DiagnosticEventCode.discoveryFailed.rawValue == 46) - #expect(DiagnosticEventCode.admissionSucceeded.rawValue == 47) - #expect(DiagnosticEventCode.admissionFailed.rawValue == 48) - #expect(DiagnosticEventCode.hostAuthenticationFailed.rawValue == 49) - #expect(DiagnosticEventCode.rpcFailed.rawValue == 50) - #expect(DiagnosticEventCode.transportSessionLifecycle.rawValue == 51) - #expect(Set(DiagnosticEventCode.allCases.map(\.rawValue)).count == DiagnosticEventCode.allCases.count) - } - - @Test func diagnosticTaxonomyHasStableRawValuesAndRedactedMappings() { - #expect(DiagnosticTransportKind(.iroh) == .iroh) - #expect(DiagnosticTransportKind(.tailscale) == .tailscale) - #expect(DiagnosticTransportKind(.websocket) == .websocket) - #expect(DiagnosticTransportKind(.debugLoopback) == .debugLoopback) - #expect(CmxAttachTransportKind.iroh.diagnosticTransportKind.rawValue == 1) - #expect(DiagnosticFailureKind.cancelled.rawValue == 20) - #expect(DiagnosticFailureKind.unknown.rawValue == 255) - #expect(DiagnosticSessionLifecycleKind.established.rawValue == 1) - #expect(DiagnosticSessionLifecycleKind.controlOwnerReleased.rawValue == 2) - #expect(DiagnosticSessionLifecycleKind.controlReadFailed.rawValue == 3) - #expect(DiagnosticSessionLifecycleKind.controlWriteFailed.rawValue == 4) - #expect(DiagnosticSessionLifecycleKind.remoteClosed.rawValue == 5) - #expect(DiagnosticSessionLifecycleKind.closedSessionEvicted.rawValue == 6) - #expect(DiagnosticSessionLifecycleKind.applicationLaneFailed.rawValue == 7) - #expect(DiagnosticSessionLifecycleKind.runtimeDeactivated.rawValue == 8) - #expect(DiagnosticSessionLifecycleKind.runtimeReconfigured.rawValue == 9) - #expect(DiagnosticSessionLifecycleKind.explicitlyInvalidated.rawValue == 10) - - #expect(DiagnosticPathKind(.unavailable) == .unknown) - #expect(DiagnosticPathKind(.direct) == .direct) - #expect(DiagnosticPathKind(.privateNetwork) == .privateNetwork) - #expect( - DiagnosticPathKind(.managedRelay(provider: "provider", region: "region")) == .relay - ) - #expect( - DiagnosticPathKind( - .customRelay(displayName: "private", provider: "provider", region: "region") - ) == .relay - ) - } - - @Test func failureClassifierPrefersTypedErrorsAndBoundsSystemErrors() { - #expect(DiagnosticFailureKind.classify(ClassifiedTestError.denied) == .admissionDenied) - #expect(DiagnosticFailureKind.classify(CancellationError()) == .cancelled) - #expect( - DiagnosticFailureKind.classify( - NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) - ) == .timedOut - ) - #expect( - DiagnosticFailureKind.classify( - NSError( - domain: NSPOSIXErrorDomain, - code: Int(POSIXErrorCode.EHOSTUNREACH.rawValue) - ) - ) == .hostUnreachable - ) - #expect( - DiagnosticFailureKind.classify( - NSError(domain: "contains-sensitive-provider-text", code: 9) - ) == .unknown - ) - } - - @Test func snapshotOrdersEventsMapsWallDatesAndSummarizesLatestFailure() async { - let log = DiagnosticLog( - capacity: 8, - buildStamp: "cmux DEV diag", - role: .mobileClient, - anchorWallNanos: 1_700_000_000_000_000_000, - anchorMonotonicNanos: 1_000 - ) - log.record( - DiagnosticEvent( - code: .transportDialConnected, - tNanos: 2_000, - a: Int(DiagnosticTransportKind.iroh.rawValue), - c: 7 - ) - ) - log.record( - DiagnosticEvent( - code: .transportDialFailed, - tNanos: 3_000, - a: Int(DiagnosticTransportKind.iroh.rawValue), - b: Int(DiagnosticFailureKind.noRoute.rawValue), - c: 8 - ) - ) - await waitForProcessed(log, 2) - - let generatedAt = Date(timeIntervalSince1970: 1_700_000_001) - let report = await log.snapshot(generatedAt: generatedAt) - #expect(report.schemaVersion == DiagnosticReport.currentSchemaVersion) - #expect(report.role == .mobileClient) - #expect(report.generatedAt == generatedAt) - #expect(report.buildStamp == "cmux DEV diag") - #expect(report.events.map(\.tNanos) == [2_000, 3_000]) - #expect(report.events[0].diagnosticTransportKind == .iroh) - #expect(report.events[0].diagnosticAttemptID == 7) - #expect(report.events[1].diagnosticFailureKind == .noRoute) - #expect(report.lastFailureKind == .noRoute) - #expect(report.lastFailureEvent?.code == .transportDialFailed) - #expect(report.lastSuccessEvent?.code == .transportDialConnected) - #expect( - abs((report.lastTransportConnectionDate?.timeIntervalSince1970 ?? 0) - 1_700_000_000.000_001) < 0.000_001 - ) - #expect( - abs((report.lastConnectionSuccessDate?.timeIntervalSince1970 ?? 0) - 1_700_000_000.000_001) < 0.000_001 - ) - #expect( - abs((report.lastFailureDate?.timeIntervalSince1970 ?? 0) - 1_700_000_000.000_002) < 0.000_001 - ) - } - - @Test func hostAdmissionCountsAsAConnectionSuccess() { - let report = DiagnosticReport( - role: .macHost, - anchorWallNanos: 1_000_000_000, - anchorMonotonicNanos: 10, - events: [DiagnosticEvent(code: .admissionSucceeded, tNanos: 20)] - ) - - #expect(report.lastTransportConnectionDate == nil) - #expect(report.lastConnectionSuccessDate != nil) - } - - @Test func reportInitializerSortsInputAndUsesSafeFallbackFailureKinds() { - let report = DiagnosticReport( - role: .macHost, - generatedAt: Date(timeIntervalSince1970: 5), - anchorWallNanos: 5_000_000_000, - anchorMonotonicNanos: 100, - buildStamp: "cmux\nDEV/@sensitive=value", - events: [ - DiagnosticEvent(code: .routeUnavailable, tNanos: 300), - DiagnosticEvent(code: .endpointActive, tNanos: 200), - ] - ) - - #expect(report.events.map(\.tNanos) == [200, 300]) - #expect(report.lastFailureKind == .noRoute) - #expect(report.buildStamp == "cmuxDEVsensitivevalue") - #expect(!report.buildStamp.contains("\n")) - #expect(!report.buildStamp.contains("/")) - #expect(!report.buildStamp.contains("@")) - #expect(!report.buildStamp.contains("=")) - } - - @Test func everyTypedFailureEventContributesToLatestFailureHelpers() { - let failureCodes: [DiagnosticEventCode] = [ - .transportDialFailed, - .recoveryFailed, - .endpointFailed, - .relayPolicyRefreshFailed, - .sessionClosed, - .routeUnavailable, - .discoveryFailed, - .admissionFailed, - .hostAuthenticationFailed, - .rpcFailed, - ] - - for (index, code) in failureCodes.enumerated() { - let event = DiagnosticEvent( - code: code, - tNanos: UInt64(index + 2), - b: DiagnosticFailureKind.protocolViolation.rawValue - ) - let report = DiagnosticReport( - anchorWallNanos: 1_000_000_000, - anchorMonotonicNanos: 1, - events: [event] - ) - #expect(report.lastFailureEvent == event) - #expect(report.lastFailureKind == .protocolViolation) - #expect(report.lastFailureDate != nil) - } - } - - @Test func clearStartsFreshBoundedSessionAndResetsAnchors() async { - let log = DiagnosticLog( - capacity: 2, - role: .macHost, - anchorWallNanos: 1_000, - anchorMonotonicNanos: 10 - ) - log.record(DiagnosticEvent(code: .endpointStarting, tNanos: 11)) - await waitForProcessed(log, 1) - - await log.clear(anchorWallNanos: 9_000, anchorMonotonicNanos: 90) - #expect(await log.count() == 0) - #expect(await log.processedCount() == 0) - let emptySnapshot = await log.snapshot(generatedAt: Date(timeIntervalSince1970: 9)) - #expect(emptySnapshot.role == .macHost) - #expect(emptySnapshot.anchorWallNanos == 9_000) - #expect(emptySnapshot.anchorMonotonicNanos == 90) - #expect(emptySnapshot.events.isEmpty) - - log.record(DiagnosticEvent(code: .endpointActive, tNanos: 91)) - await waitForProcessed(log, 1) - let freshSnapshot = await log.snapshot() - #expect(freshSnapshot.events.map(\.code) == [.endpointActive]) - #expect(freshSnapshot.wallDate(for: freshSnapshot.events[0]) != nil) - } - - @Test func clearBarrierPreventsBufferedOldSessionEventsFromReappearing() async { - let log = DiagnosticLog(capacity: 8) - for index in 0..<10_000 { - log.record(DiagnosticEvent(code: .rpcFailed, tNanos: UInt64(index))) - } - - await log.clear(anchorWallNanos: 10_000, anchorMonotonicNanos: 100) - - #expect(await log.count() == 0) - #expect(await log.processedCount() == 0) - - log.record(DiagnosticEvent(code: .rpcReady, tNanos: 101)) - await waitForProcessed(log, 1) - #expect((await log.snapshot()).events.map(\.code) == [.rpcReady]) - } - - @Test func consecutiveClearsChainBoundedSessionsInOrder() async { - let log = DiagnosticLog( - capacity: 2, - anchorWallNanos: 100, - anchorMonotonicNanos: 1 - ) - log.record(DiagnosticEvent(code: .endpointStarting, tNanos: 2)) - await waitForProcessed(log, 1) - - await log.clear(anchorWallNanos: 1_000, anchorMonotonicNanos: 10) - log.record(DiagnosticEvent(code: .endpointActive, tNanos: 11)) - await waitForProcessed(log, 1) - - await log.clear(anchorWallNanos: 2_000, anchorMonotonicNanos: 20) - let empty = await log.snapshot() - #expect(empty.anchorWallNanos == 2_000) - #expect(empty.anchorMonotonicNanos == 20) - #expect(empty.events.isEmpty) - #expect(await log.processedCount() == 0) - - log.record(DiagnosticEvent(code: .rpcReady, tNanos: 21)) - await waitForProcessed(log, 1) - #expect((await log.snapshot()).events.map(\.code) == [.rpcReady]) - } - - @Test func reportCodableContainsNoUnboundedErrorOrRouteFields() throws { - let report = DiagnosticReport( - role: .mobileClient, - generatedAt: Date(timeIntervalSince1970: 1), - anchorWallNanos: 1_000_000_000, - anchorMonotonicNanos: 1, - buildStamp: "cmux 1.2.3", - events: [ - DiagnosticEvent( - code: .transportDialFailed, - tNanos: 2, - a: Int(DiagnosticTransportKind.iroh.rawValue), - b: Int(DiagnosticFailureKind.authorizationFailed.rawValue), - c: 1 - ), - ] - ) - - let data = try JSONEncoder().encode(report) - let text = String(decoding: data, as: UTF8.self) - #expect(!text.contains("endpoint")) - #expect(!text.contains("address")) - #expect(!text.contains("relayURL")) - #expect(!text.contains("token")) - #expect(!text.contains("errorDescription")) - #expect(try JSONDecoder().decode(DiagnosticReport.self, from: data) == report) - } - - @Test func reportCapsEventsAndSanitizesDecodedBuildStamp() throws { - let oversized = (0..<(DiagnosticReport.maximumEventCount + 2)).map { index in - DiagnosticEvent(code: .connect, tNanos: UInt64(index)) - } - let report = DiagnosticReport(buildStamp: "safe", events: oversized) - #expect(report.events.count == DiagnosticReport.maximumEventCount) - #expect(report.events.first?.tNanos == 2) - - let encoded = try JSONEncoder().encode(report) - var object = try #require( - JSONSerialization.jsonObject(with: encoded) as? [String: Any] - ) - object["buildStamp"] = "cmux\n/private/@identity=value" - let hostileData = try JSONSerialization.data(withJSONObject: object) - let decoded = try JSONDecoder().decode(DiagnosticReport.self, from: hostileData) - #expect(decoded.buildStamp == "cmuxprivateidentityvalue") - } - - @Test func reportDecoderRejectsAnOversizedEventArrayBeforeDecodingTheExtraEvent() throws { - let maximum = DiagnosticReport.maximumEventCount - let exactEvents = (0..<maximum).map { index in - DiagnosticEvent(code: .connect, tNanos: UInt64(index)) - } - let exactReport = DiagnosticReport(events: exactEvents) - let exactData = try JSONEncoder().encode(exactReport) - #expect(try JSONDecoder().decode(DiagnosticReport.self, from: exactData).events.count == maximum) - - var object = try #require( - JSONSerialization.jsonObject(with: exactData) as? [String: Any] - ) - var encodedEvents = try #require(object["events"] as? [[String: Any]]) - encodedEvents.append(["malformed": true]) - object["events"] = encodedEvents - let oversizedData = try JSONSerialization.data(withJSONObject: object) - - do { - _ = try JSONDecoder().decode(DiagnosticReport.self, from: oversizedData) - Issue.record("Expected the oversized diagnostic report to be rejected") - } catch let DecodingError.dataCorrupted(context) { - #expect(context.debugDescription == "Diagnostic report exceeds the maximum event count.") - } catch { - Issue.record("Expected a maximum-count error before decoding the malformed extra event: \(error)") - } - } - - @Test func reportInitializerBoundsBeforeStableOrdering() { - let maximum = DiagnosticReport.maximumEventCount - var events = [ - DiagnosticEvent(code: .connect, tNanos: 99_999), - DiagnosticEvent(code: .pairOk, tNanos: 88_888), - ] - events += (0..<maximum).map { index in - DiagnosticEvent(code: .rpcReady, tNanos: UInt64(maximum - index)) - } - - let report = DiagnosticReport(events: events) - - #expect(report.events.count == maximum) - #expect(report.events.first?.tNanos == 1) - #expect(report.events.last?.tNanos == UInt64(maximum)) - #expect(!report.events.contains(where: { $0.tNanos == 99_999 || $0.tNanos == 88_888 })) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileHostRPCWorkQuotaTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileHostRPCWorkQuotaTests.swift deleted file mode 100644 index c357ec59..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileHostRPCWorkQuotaTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Testing -@testable import CMUXMobileCore - -@Suite -struct MobileHostRPCWorkQuotaTests { - @Test - func permitsBoundedConcurrencyThenRejectsAnotherRequest() { - let quota = MobileHostRPCWorkQuota( - maximumConcurrentRequestCount: 3, - maximumAggregateFrameByteCount: 100 - ) - - #expect(quota.allowsAdmission( - frameByteCount: 1, - activeFrameByteCounts: [10, 20] - )) - #expect(!quota.allowsAdmission( - frameByteCount: 1, - activeFrameByteCounts: [10, 20, 30] - )) - } - - @Test - func boundsAggregateDecodedBytesAcrossConcurrentRequests() { - let quota = MobileHostRPCWorkQuota( - maximumConcurrentRequestCount: 10, - maximumAggregateFrameByteCount: 100 - ) - - #expect(quota.allowsAdmission( - frameByteCount: 40, - activeFrameByteCounts: [25, 35] - )) - #expect(!quota.allowsAdmission( - frameByteCount: 41, - activeFrameByteCounts: [25, 35] - )) - #expect(!quota.allowsAdmission( - frameByteCount: 101, - activeFrameByteCounts: [] - )) - } - - @Test - func defaultBudgetAllowsOneMaximumFrameWithoutIntegerOverflow() { - let quota = MobileHostRPCWorkQuota() - - #expect(quota.allowsAdmission( - frameByteCount: MobileSyncFrameCodec.defaultMaximumFrameByteCount, - activeFrameByteCounts: [] - )) - #expect(!quota.allowsAdmission( - frameByteCount: 1, - activeFrameByteCounts: [Int.max] - )) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileSyncProtocolTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileSyncProtocolTests.swift deleted file mode 100644 index 6c97dde4..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileSyncProtocolTests.swift +++ /dev/null @@ -1,240 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func pairingPayloadRoundTripsThroughURL() throws { - let expiresAt = Date(timeIntervalSince1970: 2_000_000_000) - let payload = try MobileSyncPairingPayload( - macDeviceID: "mac-1", - macDisplayName: "Studio", - host: "100.64.1.2", - port: 49831, - expiresAt: expiresAt, - transport: .tailscale - ) - - let decoded = try MobileSyncPairingPayload.decodeURL( - payload.encodedURL(), - now: Date(timeIntervalSince1970: 1_900_000_000) - ) - - #expect(decoded == payload) -} - -@Test func pairingPayloadRejectsLongLivedSecretFields() throws { - let json = """ - { - "version": 1, - "mac_device_id": "mac-1", - "mac_display_name": "Studio", - "host": "100.64.1.2", - "port": 49831, - "expires_at": "2033-05-18T03:33:20Z", - "transport": "tailscale", - "token": "do-not-accept" - } - """ - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - do { - _ = try decoder.decode(MobileSyncPairingPayload.self, from: Data(json.utf8)) - Issue.record("Expected token-bearing payload to fail") - } catch let error as MobileSyncPairingPayloadError { - #expect(error == .forbiddenSecretField("token")) - } -} - -@Test func pairingPayloadRejectsSecretFieldNamesContainingToken() throws { - let json = """ - { - "version": 1, - "mac_device_id": "mac-1", - "mac_display_name": "Studio", - "host": "100.64.1.2", - "port": 49831, - "expires_at": "2033-05-18T03:33:20Z", - "transport": "tailscale", - "refreshToken": "do-not-accept" - } - """ - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - do { - _ = try decoder.decode(MobileSyncPairingPayload.self, from: Data(json.utf8)) - Issue.record("Expected refreshToken-bearing payload to fail") - } catch let error as MobileSyncPairingPayloadError { - #expect(error == .forbiddenSecretField("refreshToken")) - } -} - -@Test func pairingPayloadRejectsExpiredURLs() throws { - let json = """ - { - "version": 1, - "mac_device_id": "mac-1", - "host": "100.64.1.2", - "port": 49831, - "expires_at": "1970-01-01T00:16:40Z", - "transport": "tailscale" - } - """ - let url = try #require(URL(string: "cmux-ios://pair?v=1&payload=\(base64URLEncode(Data(json.utf8)))")) - - do { - _ = try MobileSyncPairingPayload.decodeURL( - url, - now: Date(timeIntervalSince1970: 1_001) - ) - Issue.record("Expected expired payload to fail") - } catch let error as MobileSyncPairingPayloadError { - #expect(error == .expired) - } -} - -@Test func pairingPayloadDirectDecodeRejectsExpiredPayloads() throws { - let json = """ - { - "version": 1, - "mac_device_id": "mac-1", - "host": "100.64.1.2", - "port": 49831, - "expires_at": "2001-01-01T00:00:00Z", - "transport": "tailscale" - } - """ - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - #expect(throws: MobileSyncPairingPayloadError.expired) { - _ = try decoder.decode(MobileSyncPairingPayload.self, from: Data(json.utf8)) - } -} - -@Test func pairingPayloadInitializerRejectsExpiredPayloads() { - do { - _ = try MobileSyncPairingPayload( - macDeviceID: "mac-1", - macDisplayName: nil, - host: "100.64.1.2", - port: 49831, - expiresAt: Date(timeIntervalSince1970: 1_000), - transport: .tailscale - ) - Issue.record("Expected initializer to reject expired payload") - } catch let error as MobileSyncPairingPayloadError { - #expect(error == .expired) - } catch { - Issue.record("Expected expired payload error, got \(error)") - } -} - -@Test func pairingPayloadDecodeURLHonorsInjectedClock() throws { - let json = """ - { - "version": 1, - "mac_device_id": "mac-1", - "host": "100.64.1.2", - "port": 49831, - "expires_at": "1970-01-01T00:16:40Z", - "transport": "tailscale" - } - """ - let url = try #require(URL(string: "cmux-ios://pair?v=1&payload=\(base64URLEncode(Data(json.utf8)))")) - - let decoded = try MobileSyncPairingPayload.decodeURL( - url, - now: Date(timeIntervalSince1970: 999) - ) - - #expect(decoded.host == "100.64.1.2") -} - -@Test func pairingPayloadSupportsDebugLoopbackWithoutChangingProductionTransport() throws { - let payload = try MobileSyncPairingPayload( - macDeviceID: "debug-mac", - macDisplayName: "Simulator Host", - host: "127.0.0.1", - port: 51111, - expiresAt: Date(timeIntervalSince1970: 2_000_000_000), - transport: .debugLoopback - ) - - let decoded = try MobileSyncPairingPayload.decodeURL( - payload.encodedURL(), - now: Date(timeIntervalSince1970: 1_900_000_000) - ) - - #expect(decoded.transport == .debugLoopback) - #expect(decoded.host == "127.0.0.1") -} - -@Test func frameCodecDecodesCompleteAndPartialFrames() throws { - let first = try MobileSyncFrameCodec.encodeFrame(Data("one".utf8)) - let second = try MobileSyncFrameCodec.encodeFrame(Data("two".utf8)) - var buffer = Data() - buffer.append(first) - buffer.append(second.prefix(5)) - - var frames = try MobileSyncFrameCodec.decodeFrames(from: &buffer) - #expect(frames == [Data("one".utf8)]) - #expect(buffer == second.prefix(5)) - - buffer.append(second.dropFirst(5)) - frames = try MobileSyncFrameCodec.decodeFrames(from: &buffer) - #expect(frames == [Data("two".utf8)]) - #expect(buffer.isEmpty) -} - -@Test func frameCodecRejectsOversizedFrames() throws { - var buffer = Data([0x00, 0x00, 0x00, 0x05]) - buffer.append(Data("hello".utf8)) - - do { - _ = try MobileSyncFrameCodec.decodeFrames(from: &buffer, maximumFrameByteCount: 4) - Issue.record("Expected oversized frame to fail") - } catch let error as MobileSyncFrameCodecError { - #expect(error == .frameTooLarge(5)) - } -} - -@Test func frameCodecRejectsAZeroLengthFrameFloodAtTheCallerLimit() throws { - let emptyFrame = try MobileSyncFrameCodec.encodeFrame(Data()) - var buffer = Data() - for _ in 0..<10_000 { - buffer.append(emptyFrame) - } - - do { - _ = try MobileSyncFrameCodec.decodeFrames( - from: &buffer, - maximumDecodedFrameCount: 16 - ) - Issue.record("Expected the decoded frame count limit to fail closed") - } catch let error as MobileSyncFrameCodecError { - #expect(error == .tooManyFrames(16)) - #expect(buffer.count == (10_000 - 16) * emptyFrame.count) - } -} - -@Test func frameCodecDefaultFrameCountLimitIsFinite() throws { - let emptyFrame = try MobileSyncFrameCodec.encodeFrame(Data()) - var buffer = Data() - for _ in 0...MobileSyncFrameCodec.defaultMaximumDecodedFrameCount { - buffer.append(emptyFrame) - } - - #expect(throws: MobileSyncFrameCodecError.tooManyFrames( - MobileSyncFrameCodec.defaultMaximumDecodedFrameCount - )) { - _ = try MobileSyncFrameCodec.decodeFrames(from: &buffer) - } -} - -private func base64URLEncode(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridEmissionTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridEmissionTests.swift deleted file mode 100644 index 97550a1a..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridEmissionTests.swift +++ /dev/null @@ -1,198 +0,0 @@ -import Testing -@testable import CMUXMobileCore - -@Test func renderGridEmissionSuppressesUnchangedOriginModeSnapshot() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 48, - columns: 8, - rows: 2, - rowSpans: [ - .init(row: 0, column: 0, text: "same"), - ], - modes: [ - .init(code: MobileTerminalRenderGridFrame.ModeSetting.decOriginModeCode, ansi: false, on: true), - ] - ) - let previous = frame.emissionState - - let emission = try frame.renderGridEmission(comparedTo: previous) - - #expect(emission == nil) -} - -@Test func renderGridEmissionKeepsCursorOnlyOriginModeUpdatesAsDeltas() throws { - let previous = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 48, - columns: 8, - rows: 2, - rowSpans: [ - .init(row: 0, column: 0, text: "same"), - ], - modes: [ - .init(code: MobileTerminalRenderGridFrame.ModeSetting.decOriginModeCode, ansi: false, on: true), - ] - ).emissionState - let next = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 49, - columns: 8, - rows: 2, - cursor: .init(row: 1, column: 3), - rowSpans: [ - .init(row: 0, column: 0, text: "same"), - ], - modes: [ - .init(code: MobileTerminalRenderGridFrame.ModeSetting.decOriginModeCode, ansi: false, on: true), - ] - ) - - let emission = try #require(try next.renderGridEmission(comparedTo: previous)) - - #expect(!emission.frame.full) - #expect(emission.frame.rowSpans.isEmpty) - #expect(emission.frame.cursor?.row == 1) - #expect(emission.state == next.emissionState) -} - -@Test func renderGridEmissionKeepsChangedOriginModeSnapshotFull() throws { - let previous = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 48, - columns: 8, - rows: 2, - rowSpans: [ - .init(row: 0, column: 0, text: "old"), - ], - modes: [ - .init(code: MobileTerminalRenderGridFrame.ModeSetting.decOriginModeCode, ansi: false, on: true), - ] - ).emissionState - let next = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 49, - columns: 8, - rows: 2, - rowSpans: [ - .init(row: 0, column: 0, text: "new"), - ], - modes: [ - .init(code: MobileTerminalRenderGridFrame.ModeSetting.decOriginModeCode, ansi: false, on: true), - ] - ) - - let emission = try #require(try next.renderGridEmission(comparedTo: previous)) - - #expect(emission.frame.full) - #expect(emission.frame.rowSpans == next.rowSpans) - #expect(emission.state == next.emissionState) -} - -@Test func renderGridEmissionKeepsScreenSwitchSnapshotFull() throws { - let previous = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 52, - columns: 8, - rows: 2, - text: "shell" - ).emissionState - let next = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 53, - columns: 8, - rows: 2, - rowSpans: [ - .init(row: 0, column: 0, text: "tui"), - ], - activeScreen: .alternate - ) - - let emission = try #require(try next.renderGridEmission(comparedTo: previous)) - - #expect(emission.frame.full) - #expect(emission.frame.activeScreen == .alternate) - #expect(emission.state == next.emissionState) -} - -@Test func renderGridEmissionKeepsNonOriginChangesAsDeltas() throws { - let previous = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 50, - columns: 8, - rows: 2, - text: "old\nsame" - ).emissionState - let next = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 51, - columns: 8, - rows: 2, - text: "new\nsame" - ) - - let emission = try #require(try next.renderGridEmission(comparedTo: previous)) - - #expect(!emission.frame.full) - #expect(emission.frame.clearedRows == [0]) - #expect(emission.frame.rowSpans == [.init(row: 0, column: 0, text: "new")]) -} - -@Test func renderGridEmissionKeepsThemeOnlyChangesAsFullSnapshots() throws { - var dark = TerminalTheme.monokai - dark.background = "#101820" - var light = dark - light.background = "#f5f1e8" - light.foreground = "#15202b" - let previous = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 50, - columns: 8, - rows: 2, - rowSpans: [.init(row: 0, column: 0, text: "same")], - terminalTheme: dark - ).emissionState - let next = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 50, - columns: 8, - rows: 2, - rowSpans: [.init(row: 0, column: 0, text: "same")], - terminalTheme: light - ) - - let emission = try #require(try next.renderGridEmission(comparedTo: previous)) - - #expect(emission.frame.full) - #expect(emission.frame.terminalTheme == light) -} - -@Test func renderGridEmissionKeepsConfigOnlyChangesAsFullSnapshots() throws { - var oldConfig = TerminalTheme.monokai - oldConfig.background = "#101820" - var newConfig = oldConfig - newConfig.background = "#f5f1e8" - let previous = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 50, - columns: 8, - rows: 2, - rowSpans: [.init(row: 0, column: 0, text: "same")], - terminalTheme: .monokai, - terminalConfigTheme: oldConfig - ).emissionState - let next = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 50, - columns: 8, - rows: 2, - rowSpans: [.init(row: 0, column: 0, text: "same")], - terminalTheme: .monokai, - terminalConfigTheme: newConfig - ) - - let emission = try #require(try next.renderGridEmission(comparedTo: previous)) - - #expect(emission.frame.full) - #expect(emission.frame.terminalConfigTheme == newConfig) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridModeReplayTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridModeReplayTests.swift deleted file mode 100644 index 925c69e8..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridModeReplayTests.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func renderGridFullSnapshotRestoresAlternateScreenAndModes() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 8, - rows: 2, - cursor: .init(row: 0, column: 0), - rowSpans: [.init(row: 0, column: 0, text: "TUI")], - activeScreen: .alternate, - modes: [ - .init(code: 1000, ansi: false, on: true), // mouse tracking (DEC private) - .init(code: 2004, ansi: false, on: true), // bracketed paste (DEC private) - .init(code: 4, ansi: true, on: true), // insert mode (ANSI, no `?`) - .init(code: 3, ansi: false, on: true), // DECCOLM: geometry handled separately - .init(code: 1049, ansi: false, on: true), // alt-screen: handled separately - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.hasPrefix("\u{1B}[?2026h\u{1B}[0$}")) - #expect(vt.hasSuffix("\u{1B}[?2026l")) - #expect(vt.contains("\u{1B}[?1049h")) // entered the alternate screen - #expect(vt.contains("\u{1B}[?1000h")) // mouse mode restored - #expect(vt.contains("\u{1B}[?2004h")) // bracketed paste restored - #expect(vt.contains("\u{1B}[4h")) // ANSI insert mode restored without `?` - #expect(vt.contains("\u{1B}[?1049l")) // left alternate before clearing primary scrollback - #expect(!vt.contains("\u{1B}[?3h")) // DECCOLM would resize away from the remote grid - // The alt-screen mode in `modes` is ignored; the two `?1049h` emissions are - // the synchronized reset prelude and the captured active screen. - #expect(vt.components(separatedBy: "\u{1B}[?1049h").count - 1 == 2) -} - -@Test func renderGridFullSnapshotDefaultsOmittedModeListBeforeCursorRestore() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 8, - rows: 1, - cursor: .init(row: 0, column: 6), - rowSpans: [.init(row: 0, column: 0, text: "legacy")] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - let content = try #require(vt.range(of: "legacy")) - let postPaintRange = content.upperBound..<vt.endIndex - - #expect(vt.range(of: "\u{1B}[?1l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[4l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?6l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?7h", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?1000l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?1006l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?2004l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?2027l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?2031l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}[?2048l", range: postPaintRange) != nil) - #expect(vt.range(of: "\u{1B}>", range: postPaintRange) != nil) -} - -@Test func renderGridFullSnapshotReappliesCapturedModesAfterDefaultBaseline() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 8, - rows: 1, - cursor: .init(row: 0, column: 4), - rowSpans: [.init(row: 0, column: 0, text: "mode")], - modes: [ - .init(code: 1, ansi: false, on: true), - .init(code: 4, ansi: true, on: true), - .init(code: 1000, ansi: false, on: true), - .init(code: 2004, ansi: false, on: true), - .init(code: 2027, ansi: false, on: true), - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - let content = try #require(vt.range(of: "mode")) - let graphemeRestore = try #require(vt.range(of: "\u{1B}[?2027h")) - let appCursorReset = try #require(vt.range(of: "\u{1B}[?1l", range: content.upperBound..<vt.endIndex)) - let appCursorRestore = try #require(vt.range(of: "\u{1B}[?1h", range: appCursorReset.upperBound..<vt.endIndex)) - let insertReset = try #require(vt.range(of: "\u{1B}[4l", range: content.upperBound..<vt.endIndex)) - let insertRestore = try #require(vt.range(of: "\u{1B}[4h", range: insertReset.upperBound..<vt.endIndex)) - let mouseReset = try #require(vt.range(of: "\u{1B}[?1000l", range: content.upperBound..<vt.endIndex)) - let mouseRestore = try #require(vt.range(of: "\u{1B}[?1000h", range: mouseReset.upperBound..<vt.endIndex)) - let pasteReset = try #require(vt.range(of: "\u{1B}[?2004l", range: content.upperBound..<vt.endIndex)) - let pasteRestore = try #require(vt.range(of: "\u{1B}[?2004h", range: pasteReset.upperBound..<vt.endIndex)) - - #expect(graphemeRestore.lowerBound < content.lowerBound) - #expect(appCursorReset.lowerBound < appCursorRestore.lowerBound) - #expect(insertReset.lowerBound < insertRestore.lowerBound) - #expect(mouseReset.lowerBound < mouseRestore.lowerBound) - #expect(pasteReset.lowerBound < pasteRestore.lowerBound) -} - -@Test func renderGridFullSnapshotResetsSemanticPromptStateOnBothScreensBeforePaint() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 7, - columns: 8, - rows: 1, - cursor: .init(row: 0, column: 4), - rowSpans: [.init(row: 0, column: 0, text: "cell")] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - let content = try #require(vt.range(of: "cell")) - let semanticReset = "\u{1B}]133;D\u{1B}\\" - // One reset per screen: the cursor's OSC 133 semantic content is - // per-screen state, and RIS (which used to clear it) is no longer sent. - let primaryReset = try #require(vt.range(of: semanticReset)) - let alternateReset = try #require( - vt.range(of: semanticReset, range: primaryReset.upperBound..<vt.endIndex) - ) - #expect(alternateReset.upperBound <= content.lowerBound) -} - -@Test func renderGridFullSnapshotOverwritesSavedModeBankAtDefaults() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 8, - columns: 8, - rows: 1, - cursor: .init(row: 0, column: 4), - rowSpans: [.init(row: 0, column: 0, text: "bank")], - modes: [ - .init(code: 2004, ansi: false, on: true), - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - let content = try #require(vt.range(of: "bank")) - // XTSAVE must snapshot the saved-mode bank while every listed mode still - // holds its default (before the frame's captured modes are reapplied and - // before content paints), replacing the saved-bank clear RIS used to do. - let firstBatch = try #require(vt.range(of: "\u{1B}[?1;3;4;5;6;7;8;9;12;25;40;45;47;66;67;69;1000;1002;1003s")) - let secondBatch = try #require(vt.range( - of: "\u{1B}[?1004;1005;1006;1007;1015;1016;1035;1036;1039;1045;1047;1048;1049;2004;2027;2031;2048s" - )) - let capturedPasteRestore = try #require(vt.range(of: "\u{1B}[?2004h")) - #expect(firstBatch.upperBound <= secondBatch.lowerBound) - #expect(secondBatch.upperBound <= content.lowerBound) - #expect(secondBatch.upperBound <= capturedPasteRestore.lowerBound) - // The bank is written once, before paint; the post-paint baseline must not - // re-save state that no longer holds defaults. - #expect(vt.components(separatedBy: "\u{1B}[?1;3;4;5;6;7;8;9;12;25;40;45;47;66;67;69;1000;1002;1003s").count - 1 == 1) -} - -@Test func renderGridFullSnapshotResetsPrimaryCursorShapeBeforeAlternateEntry() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 9, - columns: 8, - rows: 1, - cursor: .init(row: 0, column: 0, style: .bar, blinking: false), - rowSpans: [.init(row: 0, column: 0, text: "TUI")], - activeScreen: .alternate - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - // Cursor shape is per-screen state that survives the ?1049 roundtrip, so - // the primary screen must be reset to the default shape before the replay - // enters the alternate screen; otherwise a stale bar/underline shape from - // the reused surface reappears when the TUI exits. - let shapeReset = try #require(vt.range(of: "\u{1B}[0 q")) - let alternateEntry = try #require(vt.range(of: "\u{1B}[?1049h")) - #expect(shapeReset.upperBound <= alternateEntry.lowerBound) - // The frame's captured cursor shape still lands last on the active screen. - let capturedShape = try #require(vt.range(of: "\u{1B}[6 q")) - #expect(alternateEntry.upperBound <= capturedShape.lowerBound) -} - -@Test func renderGridFullSnapshotLeavesSavedCursorAtResetBaseline() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 10, - columns: 8, - rows: 2, - cursor: .init(row: 1, column: 5), - rowSpans: [.init(row: 0, column: 0, text: "shell")] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - let content = try #require(vt.range(of: "shell")) - // DECSC runs at home with the default pen for each screen so a stale - // saved cursor from the reused surface cannot survive; the snapshot - // cursor itself is never saved, so a later bare DECRC lands on the RIS - // baseline instead of the replayed cursor position. - let firstSave = try #require(vt.range(of: "\u{1B}[H\u{1B}7")) - #expect(firstSave.upperBound <= content.lowerBound) - let lastSave = try #require(vt.range(of: "\u{1B}7", options: .backwards)) - #expect( - lastSave.upperBound <= content.lowerBound, - "the replayed cursor must not be recorded as the saved cursor after paint" - ) -} - -@Test func renderGridFullSnapshotClearsDECCOLMWithoutResizing() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 11, - columns: 8, - rows: 1, - cursor: .init(row: 0, column: 0), - rowSpans: [.init(row: 0, column: 0, text: "grid")], - modes: [ - .init(code: 3, ansi: false, on: true), // captured DECCOLM stays excluded - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - // ?3l only after ?40l: with mode 40 off Ghostty clears the stored DECCOLM - // value without resizing, so the stale live/saved slot resets while the - // remote grid's geometry stays authoritative. - let allowToggle = try #require(vt.range(of: "\u{1B}[?40l")) - let deccolmClear = try #require(vt.range(of: "\u{1B}[?3l")) - let savedBank = try #require(vt.range(of: "\u{1B}[?1;3;4;")) - #expect(allowToggle.upperBound <= deccolmClear.lowerBound) - #expect(deccolmClear.upperBound <= savedBank.lowerBound) - #expect(!vt.contains("\u{1B}[?3h"), "captured DECCOLM must not be replayed as a resize") -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridPaletteReplayTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridPaletteReplayTests.swift deleted file mode 100644 index 74301b60..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridPaletteReplayTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func fullReplayRestoresEffectivePaletteOverridesAgainstRawConfig() throws { - var config = TerminalTheme.monokai - config.palette = (0..<TerminalTheme.extendedPaletteCount).map { - String(format: "#%06x", $0) - } - var effective = config - effective.palette[4] = "#123456" - effective.palette[200] = "#abcdef" - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-palette", - stateSeq: 1, - columns: 2, - rows: 1, - rowSpans: [], - terminalTheme: effective, - terminalConfigTheme: config - ) - - let replay = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - - #expect(replay.contains("\u{1B}]104\u{1B}\\")) - #expect(replay.contains("\u{1B}]4;4;rgb:12/34/56\u{1B}\\")) - #expect(replay.contains("\u{1B}]4;200;rgb:ab/cd/ef\u{1B}\\")) - #expect(!replay.contains("\u{1B}]4;5;")) -} - -@Test func fullReplayWithoutThemePreservesUnrepresentedPaletteOverrides() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-legacy", - stateSeq: 1, - columns: 2, - rows: 1, - rowSpans: [] - ) - - let replay = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - - #expect(!replay.contains("\u{1B}]104")) - #expect(!replay.contains("\u{1B}]4;0;")) -} - -@Test func fullReplayWithBasePaletteResetsOnlyRepresentedIndices() throws { - var effective = TerminalTheme.monokai - effective.palette[4] = "#123456" - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-base-palette", - stateSeq: 1, - columns: 2, - rows: 1, - rowSpans: [], - terminalTheme: effective, - terminalConfigTheme: .monokai - ) - - let replay = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - - #expect(replay.contains("\u{1B}]104;0;1;2;3;4;5;6;7;8;9;10;11;12;13;14;15\u{1B}\\")) - #expect(!replay.contains("\u{1B}]104\u{1B}\\")) - #expect(replay.contains("\u{1B}]4;4;rgb:12/34/56\u{1B}\\")) - #expect(!replay.contains("\u{1B}]104;16")) -} - -@Test func semanticDefaultStyleSurvivesColdReplayForLaterThemeChanges() throws { - let json = Data( - """ - { - "format": "cmux.render-grid.v1", - "surface_id": "semantic-theme", - "state_seq": 1, - "columns": 4, - "rows": 1, - "full": true, - "styles": [{ - "id": 0, - "foreground": "#FDFFF1", - "background": "#272822", - "foreground_source": "default", - "background_source": "default" - }], - "row_spans": [{ - "row": 0, - "column": 0, - "style_id": 0, - "cell_width": 4, - "text": "test" - }] - } - """.utf8 - ) - - let frame = try MobileTerminalRenderGridFrame.decode(json) - let replay = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - - #expect(replay.contains("\u{1B}[0;39;49m")) - #expect(!replay.contains("48;2;39;40;34")) -} - -@Test func boldStyleRetainsSemanticForegroundForLaterThemeChanges() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "bold-color", - stateSeq: 1, - columns: 4, - rows: 1, - styles: [ - .init( - id: 0, - foreground: "#4e2a84", - foregroundSource: .defaultColor, - bold: true - ), - ], - rowSpans: [ - .init(row: 0, column: 0, styleID: 0, text: "bold", cellWidth: 4), - ] - ) - - let replay = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - - #expect(replay.contains("\u{1B}[0;1;39m")) - #expect(!replay.contains("\u{1B}[0;1;38;2;78;42;132m")) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridReplayColumnTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridReplayColumnTests.swift deleted file mode 100644 index c23c5679..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridReplayColumnTests.swift +++ /dev/null @@ -1,287 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func renderGridReplayPinsGlyphsToProducerColumnsWhenConsumerWidthDiffers() throws { - let text = "A▶B界C🏁De\u{301}Z" - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 48, - columns: 16, - rows: 1, - full: false, - clearedRows: [0], - rowSpans: [ - .init(row: 0, column: 0, text: text, cellWidth: 12), - ] - ) - - let cells = try replayedCells( - from: frame.vtPatchBytes(), - rows: frame.rows, - columns: frame.columns - ) { character in - switch character { - case "界", "🏁": - return 2 - default: - return 1 - } - } - - let expectedRow: [Character?] = [ - "A", "▶", nil, "B", "界", nil, "C", "🏁", - nil, "D", "e\u{301}", "Z", nil, nil, nil, nil, - ] - #expect(cells[0] == expectedRow) -} - -@Test func renderGridReplayDoesNotInferColumnsFromAmbiguousAggregateWidth() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 49, - columns: 4, - rows: 1, - full: false, - clearedRows: [0], - rowSpans: [ - .init(row: 0, column: 0, text: "α🇰🇷B", cellWidth: 4), - ] - ) - - #expect(String(data: frame.vtPatchBytes(), encoding: .utf8) == - "\u{1B}[s\u{1B}[?6l\u{1B}[?7l" + - "\u{1B}[0m\u{1B}[1;1H\u{1B}[2K" + - "\u{1B}[1;1H\u{1B}[0mα🇰🇷B" + - "\u{1B}[0m\u{1B}[?7h\u{1B}[u" - ) -} - -@Test func renderGridReplaySanitizesC1ControlScalars() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 50, - columns: 3, - rows: 1, - full: false, - clearedRows: [0], - rowSpans: [ - .init(row: 0, column: 0, text: "A\u{9B}B", cellWidth: 3), - ] - ) - - #expect(String(data: frame.vtPatchBytes(), encoding: .utf8) == - "\u{1B}[s\u{1B}[?6l\u{1B}[?7l" + - "\u{1B}[0m\u{1B}[1;1H\u{1B}[2K" + - "\u{1B}[1;1H\u{1B}[0mA B" + - "\u{1B}[0m\u{1B}[?7h\u{1B}[u" - ) -} - -@Test func renderGridDeltaReplaysAbsoluteRowsWhenOriginModeIsActive() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 51, - columns: 8, - rows: 4, - full: false, - clearedRows: [0], - rowSpans: [ - .init(row: 0, column: 0, text: "alpha"), - ], - modes: [ - .init(code: 6, ansi: false, on: true), - .init(code: 7, ansi: false, on: true), - ] - ) - - var bytes = Data("\u{1B}[2;4r\u{1B}[?6h".utf8) - bytes.append(frame.vtPatchBytes()) - let rows = renderedRows(try replayedCells( - from: bytes, - rows: frame.rows, - columns: frame.columns, - initialRows: [ - "────────", - "row-one!", - "row-two!", - "row-tre!", - ] - )) - - #expect(rows[0] == "alpha ") - #expect(rows[1] == "row-one!") - #expect(!rows[0].contains("─")) -} - -@Test func renderGridDeltaNormalizesOriginModeWhenAutowrapIsImplicitDefault() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 52, - columns: 8, - rows: 4, - full: false, - clearedRows: [0], - rowSpans: [ - .init(row: 0, column: 0, text: "alpha"), - ] - ) - - var bytes = Data("\u{1B}[2;4r\u{1B}[?6h".utf8) - bytes.append(frame.vtPatchBytes()) - let rows = renderedRows(try replayedCells( - from: bytes, - rows: frame.rows, - columns: frame.columns, - initialRows: [ - "────────", - "row-one!", - "row-two!", - "row-tre!", - ] - )) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.hasPrefix("\u{1B}[s\u{1B}[?6l\u{1B}[?7l")) - #expect(vt.hasSuffix("\u{1B}[0m\u{1B}[?7h\u{1B}[u")) - #expect(rows[0] == "alpha ") - #expect(rows[1] == "row-one!") -} - -private func replayedCells( - from data: Data, - rows: Int, - columns: Int, - initialRows: [String] = [], - widthOf: (Character) -> Int = { _ in 1 } -) throws -> [[Character?]] { - let text = try #require(String(data: data, encoding: .utf8)) - var cells = initialRows.isEmpty - ? Array(repeating: Array<Character?>(repeating: nil, count: columns), count: rows) - : cellRows(from: initialRows, rows: rows, columns: columns) - var row = 0 - var column = 0 - var originMode = false - var scrollRegionTop = 0 - var index = text.startIndex - while index < text.endIndex { - if text[index] == "\u{1B}" { - index = consumeEscape( - in: text, - from: index, - row: &row, - column: &column, - originMode: &originMode, - scrollRegionTop: &scrollRegionTop, - cells: &cells - ) - continue - } - if text[index] == "\r" { - column = 0 - index = text.index(after: index) - continue - } - if text[index] == "\n" { - row += 1 - index = text.index(after: index) - continue - } - - let next = text.index(after: index) - let character = Character(String(text[index..<next])) - if cells.indices.contains(row), cells[row].indices.contains(column) { - cells[row][column] = character - } - column += max(1, widthOf(character)) - index = next - } - return cells -} - -private func consumeEscape( - in text: String, - from escapeIndex: String.Index, - row: inout Int, - column: inout Int, - originMode: inout Bool, - scrollRegionTop: inout Int, - cells: inout [[Character?]] -) -> String.Index { - var index = text.index(after: escapeIndex) - guard index < text.endIndex else { return index } - guard text[index] == "[" else { - return text.index(after: index) - } - index = text.index(after: index) - let parametersStart = index - while index < text.endIndex, !isCSIFinalByte(text[index]) { - index = text.index(after: index) - } - guard index < text.endIndex else { return index } - let parameters = String(text[parametersStart..<index]) - switch text[index] { - case "H", "f": - let values = csiIntegerParameters(parameters) - let rowBase = originMode ? scrollRegionTop : 0 - row = rowBase + max(0, (values.first ?? 1) - 1) - column = max(0, (values.dropFirst().first ?? 1) - 1) - case "G": - column = max(0, (csiIntegerParameters(parameters).first ?? 1) - 1) - case "K": - if parameters == "2", cells.indices.contains(row) { - cells[row] = Array<Character?>(repeating: nil, count: cells[row].count) - } - case "h", "l": - let values = csiIntegerParameters(parameters) - if parameters.hasPrefix("?"), values.contains(6) { - originMode = text[index] == "h" - row = originMode ? scrollRegionTop : 0 - column = 0 - } - case "r": - let values = csiIntegerParameters(parameters) - scrollRegionTop = max(0, (values.first ?? 1) - 1) - row = 0 - column = 0 - default: - break - } - return text.index(after: index) -} - -private func isCSIFinalByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x40...0x7E).contains(scalar.value) -} - -private func csiIntegerParameters(_ parameters: String) -> [Int] { - parameters - .split(separator: ";") - .map { component in - let digits = component.drop { !$0.isNumber } - return Int(digits) ?? 1 - } -} - -private func cellRows(from rows: [String], rows rowCount: Int, columns: Int) -> [[Character?]] { - var cells = Array( - repeating: Array<Character?>(repeating: nil, count: columns), - count: rowCount - ) - for (row, text) in rows.prefix(rowCount).enumerated() { - for (column, character) in text.prefix(columns).enumerated() { - cells[row][column] = character - } - } - return cells -} - -private func renderedRows(_ cells: [[Character?]]) -> [String] { - cells.map { row in - String(row.map { $0 ?? " " }) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift deleted file mode 100644 index 2187cabb..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridSnapshotReplayTests.swift +++ /dev/null @@ -1,245 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func renderGridFullSnapshotDoesNotPresentBlankFrameBeforeContent() throws { - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 42, - columns: 8, - rows: 2, - text: "visible\nrow", - cursor: .init(row: 1, column: 3) - ) - - let presentedFrames = try ReplayPresentationProbe.presentedRows( - from: frame.vtReplacementBytes(), - rows: frame.rows, - columns: frame.columns - ) - - #expect(presentedFrames.contains { frameRows in - frameRows.contains { $0.contains("visible") } - }) - #expect( - !presentedFrames.contains(where: { frameRows in - frameRows.allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty } - }), - "full replay must not present the empty reset frame before synchronized snapshot content lands" - ) -} - -@Test func renderGridFullSnapshotEndsActiveHyperlinkBeforePaintingContent() throws { - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 43, - columns: 8, - rows: 1, - text: "safe", - cursor: .init(row: 0, column: 4) - ) - - let linkedText = try ReplayHyperlinkProbe.hyperlinkedTextPainted(from: frame.vtReplacementBytes()) - - #expect( - linkedText.isEmpty, - "full replay must terminate any previously active OSC 8 hyperlink before painting snapshot cells" - ) -} - -@Test func renderGridFullSnapshotClearsWithFrameDefaultBackground() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 44, - columns: 8, - rows: 2, - cursor: .init(row: 0, column: 1), - styles: [ - .init(id: 0, background: "#112233"), - ], - rowSpans: [ - .init(row: 0, column: 0, styleID: 0, text: "x"), - ] - ) - - let clearBackgrounds = try ReplayClearStyleProbe.clearBackgrounds(from: frame.vtReplacementBytes()) - - #expect(!clearBackgrounds.isEmpty) - #expect( - clearBackgrounds.allSatisfy { $0 == "#112233" }, - "full replay clears must use the frame default background, not stale cursor style" - ) -} - -private struct ReplayPresentationProbe { - static func presentedRows(from data: Data, rows: Int, columns: Int) throws -> [[String]] { - let text = try #require(String(data: data, encoding: .utf8)) - var probe = ReplayPresentationProbe(rows: rows, columns: columns) - probe.consume(text) - return probe.presentedFrames - } - - private let rows: Int - private let columns: Int - private var cells: [[Character]] - private var row = 0 - private var column = 0 - private var synchronized = false - private(set) var presentedFrames: [[String]] = [] - - private init(rows: Int, columns: Int) { - self.rows = rows - self.columns = columns - cells = Array( - repeating: Array(repeating: Character(" "), count: columns), - count: rows - ) - } - - private mutating func consume(_ text: String) { - var index = text.startIndex - while index < text.endIndex { - switch text[index] { - case "\u{1B}": - index = consumeEscape(in: text, from: index) - case "\u{0F}": - index = text.index(after: index) - case "\r": - column = 0 - index = text.index(after: index) - case "\n": - row = min(row + 1, max(rows - 1, 0)) - index = text.index(after: index) - case "\r\n": - // Swift clusters CRLF into one Character; treat it as CR + LF - // so the flow separator is not painted into a cell. - column = 0 - row = min(row + 1, max(rows - 1, 0)) - index = text.index(after: index) - default: - if cells.indices.contains(row), cells[row].indices.contains(column) { - cells[row][column] = text[index] - } - column = min(column + 1, max(columns - 1, 0)) - index = text.index(after: index) - } - } - } - - private mutating func consumeEscape(in text: String, from escapeIndex: String.Index) -> String.Index { - var index = text.index(after: escapeIndex) - guard index < text.endIndex else { return index } - if text[index] == "c" { - clearScreen() - presentIfUnsynchronized() - return text.index(after: index) - } - if text[index] == "]" { - return consumeOSC(in: text, from: text.index(after: index)) - } - guard text[index] == "[" else { - while index < text.endIndex, isESCIntermediateByte(text[index]) { - index = text.index(after: index) - } - return index < text.endIndex ? text.index(after: index) : index - } - index = text.index(after: index) - let parametersStart = index - while index < text.endIndex, !isCSIFinalByte(text[index]) { - index = text.index(after: index) - } - guard index < text.endIndex else { return index } - let parameters = String(text[parametersStart..<index]) - consumeCSI(parameters: parameters, final: text[index]) - return text.index(after: index) - } - - private mutating func consumeOSC(in text: String, from oscIndex: String.Index) -> String.Index { - var index = oscIndex - while index < text.endIndex { - if text[index] == "\u{07}" { - return text.index(after: index) - } - if text[index] == "\u{1B}" { - let next = text.index(after: index) - if next < text.endIndex, text[next] == "\\" { - return text.index(after: next) - } - } - index = text.index(after: index) - } - return index - } - - private mutating func consumeCSI(parameters: String, final: Character) { - switch final { - case "h" where parameters == "?2026": - synchronized = true - case "l" where parameters == "?2026": - synchronized = false - recordFrame() - case "H", "f": - let values = csiIntegerParameters(parameters) - row = min(max((values.first ?? 1) - 1, 0), max(rows - 1, 0)) - column = min(max((values.dropFirst().first ?? 1) - 1, 0), max(columns - 1, 0)) - case "G": - column = min(max((csiIntegerParameters(parameters).first ?? 1) - 1, 0), max(columns - 1, 0)) - case "J": - if parameters.contains("2") { - clearScreen() - presentIfUnsynchronized() - } - case "K": - if parameters.contains("2"), cells.indices.contains(row) { - cells[row] = Array(repeating: Character(" "), count: columns) - presentIfUnsynchronized() - } - default: - break - } - } - - private mutating func clearScreen() { - cells = Array( - repeating: Array(repeating: Character(" "), count: columns), - count: rows - ) - row = 0 - column = 0 - } - - private mutating func presentIfUnsynchronized() { - if !synchronized { - recordFrame() - } - } - - private mutating func recordFrame() { - presentedFrames.append(cells.map { String($0) }) - } - - private func isCSIFinalByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x40...0x7E).contains(scalar.value) - } - - private func isESCIntermediateByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x20...0x2F).contains(scalar.value) - } - - private func csiIntegerParameters(_ parameters: String) -> [Int] { - parameters - .split(separator: ";") - .map { component in - let digits = component.drop { !$0.isNumber } - return Int(digits) ?? 1 - } - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift deleted file mode 100644 index 9aad7cd2..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift +++ /dev/null @@ -1,563 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func renderGridFrameEncodesVisibleRowsAndCursor() throws { - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 42, - columns: 8, - rows: 4, - text: "alpha \n\n beta\n", - cursor: .init(row: 2, column: 5) - ) - - #expect(frame.rowSpans == [ - .init(row: 0, column: 0, text: "alpha"), - .init(row: 2, column: 0, text: " beta"), - ]) - - let decoded = try MobileTerminalRenderGridFrame.decodeJSONObject(frame.jsonObject()) - #expect(decoded == frame) - let actual = String(data: frame.vtReplacementBytes(), encoding: .utf8) - let modeBaseline = [ - "\u{1B}[2l\u{1B}[4l\u{1B}[12h\u{1B}[20l", - "\u{1B}[?1l\u{1B}[?4l\u{1B}[?5l\u{1B}[?6l\u{1B}[?7h\u{1B}[?8l\u{1B}[?9l", - "\u{1B}[?40l\u{1B}[?3l\u{1B}[?45l\u{1B}[?66l\u{1B}>\u{1B}[?67l\u{1B}[?69l", - "\u{1B}[?1000l\u{1B}[?1002l\u{1B}[?1003l\u{1B}[?1004l", - "\u{1B}[?1005l\u{1B}[?1006l\u{1B}[?1007h\u{1B}[?1015l\u{1B}[?1016l", - "\u{1B}[?1035h\u{1B}[?1036h\u{1B}[?1039l\u{1B}[?1045l\u{1B}[?2004l", - "\u{1B}[?2027l\u{1B}[?2031l\u{1B}[?2048l", - ].joined() - let expected = [ - "\u{1B}[?2026h\u{1B}[0$}\u{1B}[>m\u{1B}[r\u{1B}[?69l\u{1B}[?5W", - "\u{1B}[?47l\u{1B}[?1047l\u{1B}[?1049l", - "\u{1B}]8;;\u{1B}\\", - "\u{1B}]133;D\u{1B}\\", - "\u{1B}[0 q\u{1B}[1\"q\u{1B}[0\"q\u{1B}[999<u\u{1B}[0;1=u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B", - modeBaseline, - "\u{1B}[?12l\u{1B}[?25h\u{1B}[?1048l", - "\u{1B}[?1;3;4;5;6;7;8;9;12;25;40;45;47;66;67;69;1000;1002;1003s", - "\u{1B}[?1004;1005;1006;1007;1015;1016;1035;1036;1039;1045;1047;1048;1049;2004;2027;2031;2048s", - "\u{1B}]110\u{1B}\\\u{1B}]111\u{1B}\\\u{1B}]112\u{1B}\\", - "\u{1B}[0m", - "\u{1B}[H\u{1B}7\u{1B}[2J\u{1B}[3J\u{1B}[?1049h", - "\u{1B}]8;;\u{1B}\\", - "\u{1B}]133;D\u{1B}\\", - "\u{1B}[0 q\u{1B}[1\"q\u{1B}[0\"q\u{1B}[999<u\u{1B}[0;1=u\u{0F}\u{1B}(B\u{1B})B\u{1B}*B\u{1B}+B", - "\u{1B}[0m", - "\u{1B}[H\u{1B}7\u{1B}[2J\u{1B}[?1049l\u{1B}[H", - "\u{1B}[?7l\u{1B}[?25l\u{1B}[0m", - "\u{1B}[0m\u{1B}[1Galpha", - "\r\n\u{1B}[0m", - "\r\n\u{1B}[0m\u{1B}[1G beta", - "\r\n\u{1B}[0m", - modeBaseline, - "\u{1B}[0m\u{1B}[2 q\u{1B}[?25h\u{1B}[3;6H", - "\u{1B}[?2026l", - ].joined() - #expect(actual == expected) -} - -@Test func renderGridDeltaClearsOnlyChangedRows() throws { - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 43, - columns: 8, - rows: 4, - text: "alpha\nchanged\n\nomega", - full: false, - changedRows: [1, 2] - ) - - #expect(frame.full == false) - #expect(frame.clearedRows == [1, 2]) - #expect(frame.rowSpans == [ - .init(row: 1, column: 0, text: "changed"), - ]) - #expect(String(data: frame.vtPatchBytes(), encoding: .utf8) == - "\u{1B}[s\u{1B}[?6l\u{1B}[?7l\u{1B}[0m\u{1B}[2;1H\u{1B}[2K" + - "\u{1B}[0m\u{1B}[3;1H\u{1B}[2K" + - "\u{1B}[2;1H\u{1B}[0mchanged" + - "\u{1B}[0m\u{1B}[?7h\u{1B}[u" - ) -} - -@Test func renderGridDeltaClearsShortenedRowForBackspace() throws { - // A held backspace shortens the prompt line ("echo hello" -> "echo hell"). - // The delta must erase the whole row (ESC[2K) before repainting so the - // deleted trailing cell is cleared, not left stale. This is the consumer - // half of the held-backspace render path. - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 44, - columns: 12, - rows: 1, - text: "echo hell", - full: false, - changedRows: [0] - ) - - #expect(frame.full == false) - #expect(frame.clearedRows == [0]) - #expect(frame.rowSpans == [ - .init(row: 0, column: 0, text: "echo hell"), - ]) - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - // Erase the row, then repaint the shortened text. - #expect(vt.contains("\u{1B}[1;1H\u{1B}[2K")) - #expect(vt.contains("echo hell")) -} - -@Test func renderGridDeltaClearsRowEmptiedByBackspace() throws { - // Deleting an entire line leaves a row with no spans at all. The delta must - // still emit ESC[2K for that row so stale content does not survive on the - // consumer when there is nothing to repaint. - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 45, - columns: 12, - rows: 1, - text: "", - full: false, - changedRows: [0] - ) - - #expect(frame.clearedRows == [0]) - #expect(frame.rowSpans.isEmpty) - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.contains("\u{1B}[1;1H\u{1B}[2K")) -} - -@Test func renderGridPatchPreservesRgbStylesAndCursorShape() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 45, - columns: 8, - rows: 4, - cursor: .init(row: 1, column: 2, style: .bar, blinking: false), - styles: [ - .init(id: 0, foreground: "#C0C0C0", background: "#101010"), - .init( - id: 1, - foreground: "#FF0000", - background: "#0000FF", - bold: true, - underline: true - ), - ], - rowSpans: [ - .init(row: 0, column: 0, styleID: 1, text: "red"), - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.contains("\u{1B}[0;38;2;192;192;192;48;2;16;16;16m")) - #expect(vt.contains("\u{1B}[0;1;4;38;2;255;0;0;48;2;0;0;255mred")) - #expect(vt.contains("\u{1B}[6 q\u{1B}[?25h\u{1B}[2;3H")) -} - -@Test func renderGridFilteredRowsKeepStyledSpans() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 46, - columns: 8, - rows: 4, - styles: [ - .init(id: 0, foreground: "#FFFFFF", background: "#000000"), - .init(id: 1, foreground: "#00FF00", background: "#000000"), - ], - rowSpans: [ - .init(row: 0, column: 0, text: "same"), - .init(row: 1, column: 0, styleID: 1, text: "green"), - ] - ) - - let delta = try frame.filteredRows([1], full: false) - - #expect(delta.full == false) - #expect(delta.clearedRows == [1]) - #expect(delta.styles == frame.styles) - #expect(delta.rowSpans == [.init(row: 1, column: 0, styleID: 1, text: "green")]) - let patch = try #require(String(data: delta.vtPatchBytes(), encoding: .utf8)) - #expect(patch.contains("\u{1B}[0;38;2;0;255;0;48;2;0;0;0mgreen")) -} - -@Test func renderGridFilteredDeltaKeepsOnlyReplayRestoredModeState() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 47, - columns: 8, - rows: 1, - styles: [.default], - rowSpans: [ - .init(row: 0, column: 0, text: "line"), - ], - modes: [ - .init(code: 6, ansi: false, on: true), - .init(code: 7, ansi: false, on: false), - .init(code: 1000, ansi: false, on: true), - .init(code: 4, ansi: true, on: true), - ] - ) - - let delta = try frame.filteredRows([0], full: false) - - #expect(delta.modes == [.init(code: 7, ansi: false, on: false)]) - let vt = try #require(String(data: delta.vtPatchBytes(), encoding: .utf8)) - #expect(vt.hasPrefix("\u{1B}[s\u{1B}[?6l\u{1B}[?7l")) - #expect(vt.hasSuffix("\u{1B}[0m\u{1B}[?7l\u{1B}[u")) - #expect(!vt.contains("\u{1B}[?6h")) - #expect(!vt.contains("\u{1B}[?1000h")) - #expect(!vt.contains("\u{1B}[4h")) -} - -@Test func renderGridDeltaRestoresHiddenCursorWithoutOriginMode() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 48, - columns: 8, - rows: 4, - cursor: .init(row: 2, column: 3, visible: false), - full: false, - clearedRows: [0], - styles: [.default], - rowSpans: [ - .init(row: 0, column: 0, text: "line"), - ], - modes: [ - .init(code: 6, ansi: false, on: true), - .init(code: 7, ansi: false, on: true), - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.hasPrefix("\u{1B}[?6l\u{1B}[?7l")) - #expect(vt.hasSuffix("\u{1B}[0m\u{1B}[?7h\u{1B}[2 q\u{1B}[?25l\u{1B}[3;4H")) - #expect(!vt.contains("\u{1B}[?6h")) -} - -@Test func renderGridSpanCellWidthSupportsWideCells() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 48, - columns: 2, - rows: 1, - rowSpans: [ - .init(row: 0, column: 0, text: "界", cellWidth: 2), - ] - ) - - #expect(frame.plainRows() == ["界 "]) -} - -@Test func renderGridPlainRowsClipWideFallbackByGridColumns() throws { - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: 49, - columns: 2, - rows: 1, - text: "界A" - ) - - #expect(frame.rowSpans == [ - .init(row: 0, column: 0, text: "界"), - ]) - #expect(frame.plainRows() == ["界 "]) -} - -@Test func renderGridPlainRowsClipCurrentWideFallbackRanges() throws { - let tangut = String(try #require(UnicodeScalar(0x17000))) - let meltingFace = "\u{1FAE0}" - - for (offset, text) in [tangut, meltingFace].enumerated() { - let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "terminal-a", - stateSeq: UInt64(50 + offset), - columns: 2, - rows: 1, - text: text + "A" - ) - - #expect(frame.rowSpans == [ - .init(row: 0, column: 0, text: text), - ]) - #expect(frame.plainRows() == [text + " "]) - } -} - -@Test func renderGridPreviousShapeKeepsWidthOneSymbolsNarrow() throws { - let object: [String: Any] = [ - "format": MobileTerminalRenderGridFrame.currentFormat, - "surface_id": "terminal-a", - "state_seq": NSNumber(value: 51), - "columns": 1, - "rows": 1, - "styles": [["id": 0]], - "row_spans": [ - ["row": 0, "column": 0, "style_id": 0, "text": "\u{1F0A1}"], - ], - ] - - let frame = try MobileTerminalRenderGridFrame.decodeJSONObject(object) - - #expect(frame.rowSpans == [.init(row: 0, column: 0, text: "\u{1F0A1}")]) - #expect(frame.plainRows() == ["\u{1F0A1}"]) -} - -@Test func renderGridDecodesReplayFramesFromPreviousShape() throws { - let object: [String: Any] = [ - "format": MobileTerminalRenderGridFrame.currentFormat, - "surface_id": "terminal-a", - "state_seq": NSNumber(value: 44), - "columns": 8, - "rows": 4, - "styles": [["id": 0]], - "row_spans": [ - ["row": 0, "column": 0, "style_id": 0, "text": "alpha"], - ], - ] - - let frame = try MobileTerminalRenderGridFrame.decodeJSONObject(object) - - #expect(frame.full) - #expect(frame.clearedRows.isEmpty) - #expect(frame.rowSpans == [.init(row: 0, column: 0, text: "alpha")]) -} - -@Test func renderGridRejectsInvalidSpanCoordinates() throws { - #expect(throws: MobileTerminalRenderGridError.invalidColumn(9)) { - _ = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 8, - rows: 4, - rowSpans: [ - .init(row: 0, column: 9, text: "overflow"), - ] - ) - } -} - -@Test func renderGridRowSignaturesDetectStyleOnlyChanges() throws { - // Same text, but the cell flips from a dimmed (faint) autosuggestion style - // to the normal style — as when a character is typed over a zsh suggestion. - // plainRows() is identical, so a text-only diff would miss it; the signature - // must differ so the row is re-sent. - let dim = try MobileTerminalRenderGridFrame( - surfaceID: "t", - stateSeq: 1, - columns: 8, - rows: 1, - styles: [.default, .init(id: 1, faint: true)], - rowSpans: [.init(row: 0, column: 0, styleID: 1, text: "ls")] - ) - let normal = try MobileTerminalRenderGridFrame( - surfaceID: "t", - stateSeq: 2, - columns: 8, - rows: 1, - styles: [.default], - rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "ls")] - ) - - #expect(dim.plainRows() == normal.plainRows()) // text-only diff would miss it - #expect(dim.rowSignatures() != normal.rowSignatures()) - #expect(dim.rowSignatures() == dim.rowSignatures()) // stable - - // Identical content (different per-frame style ids, same resolved style) - // produces an identical signature, so unchanged rows are not re-sent. - let sameA = try MobileTerminalRenderGridFrame( - surfaceID: "t", stateSeq: 3, columns: 8, rows: 1, - styles: [.init(id: 0, foreground: "#FF0000")], - rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "hi")] - ) - let sameB = try MobileTerminalRenderGridFrame( - surfaceID: "t", stateSeq: 4, columns: 8, rows: 1, - styles: [.default, .init(id: 1, foreground: "#FF0000")], - rowSpans: [.init(row: 0, column: 0, styleID: 1, text: "hi")] - ) - #expect(sameA.rowSignatures() == sameB.rowSignatures()) -} - -@Test func renderGridFullSnapshotFlowsScrollbackBeforeViewport() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 8, - rows: 2, - cursor: .init(row: 1, column: 0), - rowSpans: [ - .init(row: 0, column: 0, text: "vp0"), - .init(row: 1, column: 0, text: "vp1"), - ], - scrollbackRows: 2, - scrollbackSpans: [ - .init(row: 0, column: 0, text: "old0"), - .init(row: 1, column: 0, text: "old1"), - ] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - let old0 = try #require(vt.range(of: "old0")) - let old1 = try #require(vt.range(of: "old1")) - let vp0 = try #require(vt.range(of: "vp0")) - let vp1 = try #require(vt.range(of: "vp1")) - #expect(old0.lowerBound < old1.lowerBound) - #expect(old1.lowerBound < vp0.lowerBound) - #expect(vp0.lowerBound < vp1.lowerBound) - // 2 scrollback + 2 viewport rows flow as one continuous block (3 CRLFs). - #expect(vt.components(separatedBy: "\r\n").count - 1 == 3) -} - -@Test func renderGridFullSnapshotRestoresDynamicColors() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 4, - rows: 1, - rowSpans: [], - terminalForeground: "#AABBCC", - terminalBackground: "#102030", - terminalCursorColor: "#FFEEDD" - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.contains("\u{1B}]10;rgb:aa/bb/cc\u{1B}\\")) - #expect(vt.contains("\u{1B}]11;rgb:10/20/30\u{1B}\\")) - #expect(vt.contains("\u{1B}]12;rgb:ff/ee/dd\u{1B}\\")) -} - -@Test func renderGridFullSnapshotPreservesV1RawDefaultsWithReverseMode() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 4, - rows: 1, - rowSpans: [], - modes: [.init(code: 5, ansi: false, on: true)], - terminalForeground: "#111111", - terminalBackground: "#EEEEEE" - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.contains("\u{1B}]10;rgb:11/11/11\u{1B}\\")) - #expect(vt.contains("\u{1B}]11;rgb:ee/ee/ee\u{1B}\\")) - #expect(vt.contains("\u{1B}[?5h")) -} - -@Test func renderGridFullSnapshotResetsDefaultDynamicColors() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 4, - rows: 1, - rowSpans: [] - ) - - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(vt.contains("\u{1B}]110\u{1B}\\")) - #expect(vt.contains("\u{1B}]111\u{1B}\\")) - #expect(vt.contains("\u{1B}]112\u{1B}\\")) -} - -@Test func renderGridEncodesFullStateFields() throws { - var terminalTheme = TerminalTheme.monokai - terminalTheme.background = "#f5f1e8" - terminalTheme.foreground = "#15202b" - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 7, - columns: 8, - rows: 2, - rowSpans: [.init(row: 0, column: 0, text: "hi")], - activeScreen: .alternate, - modes: [ - .init(code: 1, ansi: false, on: true), - .init(code: 20, ansi: true, on: false), - ], - terminalForeground: "#010203", - terminalTheme: terminalTheme, - terminalConfigTheme: .monokai, - scrollbackRows: 1, - scrollbackSpans: [.init(row: 0, column: 0, text: "sb")] - ) - - let decoded = try MobileTerminalRenderGridFrame.decodeJSONObject(frame.jsonObject()) - #expect(decoded == frame) - #expect(decoded.activeScreen == .alternate) - #expect(decoded.modes == [ - .init(code: 1, ansi: false, on: true), - .init(code: 20, ansi: true, on: false), - ]) - #expect(decoded.scrollbackRows == 1) - #expect(decoded.scrollbackSpans == [.init(row: 0, column: 0, text: "sb")]) - #expect(decoded.terminalForeground == "#010203") - #expect(decoded.terminalTheme == terminalTheme) -} - -@Test func renderGridDeltaDropsFullStateFields() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 8, - rows: 4, - full: false, - styles: [.default], - rowSpans: [.init(row: 1, column: 0, text: "x")], - activeScreen: .alternate, - modes: [.init(code: 1000, ansi: false, on: true)], - terminalTheme: .monokai, - scrollbackRows: 3, - scrollbackSpans: [.init(row: 0, column: 0, text: "sb")] - ) - - // A delta carries no scrollback or unrelated mode transitions; it normalizes coordinates, then clears and - // repaints its changed rows. - #expect(frame.scrollbackRows == 0) - #expect(frame.scrollbackSpans.isEmpty) - #expect(frame.terminalTheme == nil) - let vt = try #require(String(data: frame.vtPatchBytes(), encoding: .utf8)) - #expect(!vt.contains("\u{1B}c")) - #expect(!vt.contains("\u{1B}[?1049h")) - #expect(!vt.contains("\u{1B}[?1000h")) -} - -@Test func replaySynthesizerMatchesFrameForwardersAcrossFrameShapes() throws { - // Full primary-screen snapshot with scrollback, styles, and a cursor. - let fullFrame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 100, - columns: 8, - rows: 3, - cursor: .init(row: 1, column: 2, style: .bar, blinking: true), - full: true, - styles: [ - .init(id: 0, foreground: "#C0C0C0", background: "#101010"), - .init(id: 1, foreground: "#FF0000", bold: true), - ], - rowSpans: [ - .init(row: 0, column: 0, styleID: 1, text: "hi"), - .init(row: 2, column: 1, styleID: 0, text: "bye"), - ], - terminalForeground: "#FFFFFF", - scrollbackRows: 1, - scrollbackSpans: [.init(row: 0, column: 0, styleID: 1, text: "past")] - ) - // Delta frame painting only changed rows. - let deltaFrame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 101, - columns: 8, - rows: 3, - full: false, - rowSpans: [.init(row: 1, column: 0, text: "delta")] - ) - - for frame in [fullFrame, deltaFrame] { - let replay = MobileTerminalRenderGridReplay(frame) - #expect(replay.patchBytes() == frame.vtPatchBytes()) - #expect(replay.replacementBytes() == frame.vtReplacementBytes()) - #expect(replay.patchBytes() == replay.replacementBytes()) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridThemePatchTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridThemePatchTests.swift deleted file mode 100644 index d1df9cd5..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridThemePatchTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Test func themePatchPreservesCellRelativeCursorColor() throws { - var theme = TerminalTheme.monokai - theme.cursor = "#123456" - theme.cursorColorSemantic = .foreground - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 4, - rows: 1, - rowSpans: [], - terminalTheme: theme, - terminalConfigTheme: theme - ) - - let patch = try #require(String( - data: MobileTerminalRenderGridReplay(frame).themePatchBytes(), - encoding: .utf8 - )) - #expect(patch.contains("\u{1B}]112\u{1B}\\")) - #expect(!patch.contains("\u{1B}]12;rgb:12/34/56\u{1B}\\")) -} - -@Test func themePatchDoesNotMutateSynchronizedOutputMode() throws { - let frame = try MobileTerminalRenderGridFrame( - surfaceID: "terminal-a", - stateSeq: 1, - columns: 4, - rows: 1, - rowSpans: [], - terminalTheme: .monokai, - terminalConfigTheme: .monokai - ) - - let patch = try #require(String( - data: MobileTerminalRenderGridReplay(frame).themePatchBytes(), - encoding: .utf8 - )) - #expect(!patch.contains("\u{1B}[?2026h")) - #expect(!patch.contains("\u{1B}[?2026l")) -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ReplayClearStyleProbe.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ReplayClearStyleProbe.swift deleted file mode 100644 index 04310ce4..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ReplayClearStyleProbe.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import Testing - -struct ReplayClearStyleProbe { - static func clearBackgrounds(from data: Data) throws -> [String] { - let text = try #require(String(data: data, encoding: .utf8)) - var probe = ReplayClearStyleProbe() - probe.consume(text) - return probe.clearBackgrounds - } - - private var activeBackground = "stale" - private var clearBackgrounds: [String] = [] - - private mutating func consume(_ text: String) { - var index = text.startIndex - while index < text.endIndex { - guard text[index] == "\u{1B}" else { - index = text.index(after: index) - continue - } - index = consumeEscape(in: text, from: index) - } - } - - private mutating func consumeEscape(in text: String, from escapeIndex: String.Index) -> String.Index { - var index = text.index(after: escapeIndex) - guard index < text.endIndex else { return index } - if text[index] == "]" { - return consumeOSC(in: text, from: text.index(after: index)) - } - guard text[index] == "[" else { - while index < text.endIndex, isESCIntermediateByte(text[index]) { - index = text.index(after: index) - } - return index < text.endIndex ? text.index(after: index) : index - } - index = text.index(after: index) - let parametersStart = index - while index < text.endIndex, !isCSIFinalByte(text[index]) { - index = text.index(after: index) - } - guard index < text.endIndex else { return index } - consumeCSI(parameters: String(text[parametersStart..<index]), final: text[index]) - return text.index(after: index) - } - - private mutating func consumeOSC(in text: String, from oscIndex: String.Index) -> String.Index { - var index = oscIndex - while index < text.endIndex { - if text[index] == "\u{07}" { - return text.index(after: index) - } - if text[index] == "\u{1B}" { - let next = text.index(after: index) - if next < text.endIndex, text[next] == "\\" { - return text.index(after: next) - } - } - index = text.index(after: index) - } - return index - } - - private mutating func consumeCSI(parameters: String, final: Character) { - switch final { - case "J" where parameters.contains("2"): - clearBackgrounds.append(activeBackground) - case "m": - applySGR(parameters) - default: - break - } - } - - private mutating func applySGR(_ parameters: String) { - let values = parameters - .split(separator: ";") - .map { Int($0) ?? 0 } - if values.isEmpty { - activeBackground = "default" - return - } - var index = 0 - while index < values.count { - switch values[index] { - case 0: - activeBackground = "default" - index += 1 - case 48 where index + 4 < values.count && values[index + 1] == 2: - activeBackground = String( - format: "#%02x%02x%02x", - values[index + 2], - values[index + 3], - values[index + 4] - ) - index += 5 - default: - index += 1 - } - } - } - - private func isCSIFinalByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x40...0x7E).contains(scalar.value) - } - - private func isESCIntermediateByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x20...0x2F).contains(scalar.value) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ReplayHyperlinkProbe.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ReplayHyperlinkProbe.swift deleted file mode 100644 index c5067777..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/ReplayHyperlinkProbe.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation -import Testing - -struct ReplayHyperlinkProbe { - static func hyperlinkedTextPainted(from data: Data) throws -> String { - let text = try #require(String(data: data, encoding: .utf8)) - var probe = ReplayHyperlinkProbe() - probe.consume(text) - return probe.hyperlinkedText - } - - private var hyperlinkActive = true - private var hyperlinkedText = "" - - private mutating func consume(_ text: String) { - var index = text.startIndex - while index < text.endIndex { - switch text[index] { - case "\u{1B}": - index = consumeEscape(in: text, from: index) - case "\u{0F}", "\r", "\n": - index = text.index(after: index) - default: - if hyperlinkActive { - hyperlinkedText.append(text[index]) - } - index = text.index(after: index) - } - } - } - - private mutating func consumeEscape(in text: String, from escapeIndex: String.Index) -> String.Index { - var index = text.index(after: escapeIndex) - guard index < text.endIndex else { return index } - if text[index] == "]" { - return consumeOSC(in: text, from: text.index(after: index)) - } - if text[index] == "[" { - index = text.index(after: index) - while index < text.endIndex, !isCSIFinalByte(text[index]) { - index = text.index(after: index) - } - return index < text.endIndex ? text.index(after: index) : index - } - while index < text.endIndex, isESCIntermediateByte(text[index]) { - index = text.index(after: index) - } - return index < text.endIndex ? text.index(after: index) : index - } - - private mutating func consumeOSC(in text: String, from oscIndex: String.Index) -> String.Index { - var index = oscIndex - var payload = "" - while index < text.endIndex { - if text[index] == "\u{07}" { - applyOSCPayload(payload) - return text.index(after: index) - } - if text[index] == "\u{1B}" { - let next = text.index(after: index) - if next < text.endIndex, text[next] == "\\" { - applyOSCPayload(payload) - return text.index(after: next) - } - } - payload.append(text[index]) - index = text.index(after: index) - } - return index - } - - private mutating func applyOSCPayload(_ payload: String) { - guard payload.hasPrefix("8;") else { return } - hyperlinkActive = payload != "8;;" - } - - private func isCSIFinalByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x40...0x7E).contains(scalar.value) - } - - private func isESCIntermediateByte(_ character: Character) -> Bool { - guard let scalar = character.unicodeScalars.first, - character.unicodeScalars.count == 1 else { - return false - } - return (0x20...0x2F).contains(scalar.value) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalGridSizeTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalGridSizeTests.swift deleted file mode 100644 index 382f1e1a..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalGridSizeTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import Testing -@testable import CMUXMobileCore - -@Suite struct TerminalGridSizeTests { - @Test func codableRoundTripPreservesAllFields() throws { - let original = TerminalGridSize(columns: 100, rows: 32, pixelWidth: 900, pixelHeight: 650) - let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(TerminalGridSize.self, from: data) - #expect(decoded == original) - #expect(decoded.columns == 100) - #expect(decoded.rows == 32) - #expect(decoded.pixelWidth == 900) - #expect(decoded.pixelHeight == 650) - } - - @Test func equalityRequiresEveryFieldToMatch() { - let base = TerminalGridSize(columns: 80, rows: 24, pixelWidth: 720, pixelHeight: 480) - #expect(base == TerminalGridSize(columns: 80, rows: 24, pixelWidth: 720, pixelHeight: 480)) - #expect(base != TerminalGridSize(columns: 81, rows: 24, pixelWidth: 720, pixelHeight: 480)) - #expect(base != TerminalGridSize(columns: 80, rows: 25, pixelWidth: 720, pixelHeight: 480)) - #expect(base != TerminalGridSize(columns: 80, rows: 24, pixelWidth: 721, pixelHeight: 480)) - #expect(base != TerminalGridSize(columns: 80, rows: 24, pixelWidth: 720, pixelHeight: 481)) - } - - @Test func equalValuesHashEqually() { - let a = TerminalGridSize(columns: 120, rows: 40, pixelWidth: 1080, pixelHeight: 800) - let b = TerminalGridSize(columns: 120, rows: 40, pixelWidth: 1080, pixelHeight: 800) - var set: Set<TerminalGridSize> = [] - set.insert(a) - set.insert(b) - #expect(set.count == 1) - } -} diff --git a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift b/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift deleted file mode 100644 index 6688074a..00000000 --- a/vendor/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalThemeTests.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation -import Testing - -@testable import CMUXMobileCore - -@Suite struct TerminalThemeTests { - @Test func monokaiDefaultIsValid() { - #expect(TerminalTheme.monokai.isValid) - #expect(TerminalTheme.monokai.palette.count == TerminalTheme.paletteCount) - } - - @Test func jsonRoundTripPreservesColors() throws { - let theme = TerminalTheme.monokai - let data = try JSONEncoder().encode(theme) - let decoded = try JSONDecoder().decode(TerminalTheme.self, from: data) - #expect(decoded == theme) - } - - @Test func decodesArbitraryThemeFromJSON() throws { - // A non-Monokai theme (Solarized Dark-ish) supplied as JSON, proving the - // app is no longer locked to a single hardcoded palette. - let json = """ - { - "background": "#002b36", - "foreground": "#839496", - "cursor": "#93a1a1", - "cursorText": "#002b36", - "selectionBackground": "#073642", - "selectionForeground": "#93a1a1", - "palette": [ - "#073642", "#dc322f", "#859900", "#b58900", - "#268bd2", "#d33682", "#2aa198", "#eee8d5", - "#002b36", "#cb4b16", "#586e75", "#657b83", - "#839496", "#6c71c4", "#93a1a1", "#fdf6e3" - ] - } - """ - let theme = try JSONDecoder().decode(TerminalTheme.self, from: Data(json.utf8)) - #expect(theme.isValid) - #expect(theme.background == "#002b36") - #expect(theme.palette[1] == "#dc322f") - #expect(theme.cursorText == "#002b36") - } - - @Test func invalidThemeFallsBackToMonokai() { - let shortPalette = TerminalTheme( - background: "#000000", - foreground: "#ffffff", - cursor: "#ffffff", - selectionBackground: "#333333", - selectionForeground: "#ffffff", - palette: ["#000000", "#ffffff"] - ) - #expect(!shortPalette.isValid) - #expect(shortPalette.validatedOrDefault() == .monokai) - - let badHex = TerminalTheme( - background: "not-a-color", - foreground: "#ffffff", - cursor: "#ffffff", - selectionBackground: "#333333", - selectionForeground: "#ffffff", - palette: Array(repeating: "#101010", count: TerminalTheme.paletteCount) - ) - #expect(!badHex.isValid) - #expect(badHex.validatedOrDefault() == .monokai) - } - - @Test func rgbComponentsParseHex() { - #expect(TerminalTheme.rgbComponents("#ff8000")! == (255, 128, 0)) - #expect(TerminalTheme.rgbComponents("ff8000")! == (255, 128, 0)) - #expect(TerminalTheme.rgbComponents("#fff") == nil) - #expect(TerminalTheme.rgbComponents("zzzzzz") == nil) - #expect(TerminalTheme.rgbComponents(nil) == nil) - } - - @Test func ghosttyDirectivesCoverAllColors() { - let directives = TerminalTheme.monokai.ghosttyColorDirectives - #expect(directives.contains("background = #272822")) - #expect(directives.contains("foreground = #fdfff1")) - #expect(directives.contains("cursor-color = #c0c1b5")) - #expect(directives.contains("selection-background = #57584f")) - #expect(directives.contains("selection-foreground = #fdfff1")) - for index in 0..<TerminalTheme.paletteCount { - #expect(directives.contains("palette = \(index)=")) - } - // No cursor-text directive when the theme leaves it nil. - #expect(!directives.contains("cursor-text =")) - } - - @Test func ghosttyDirectivesEmitCursorTextWhenPresent() { - var theme = TerminalTheme.monokai - theme.cursorText = "#8d8e82" - #expect(theme.ghosttyColorDirectives.contains("cursor-text = #8d8e82")) - } - - @Test func ghosttyDirectivesPreserveExtendedPaletteAndCellRelativeColors() { - var theme = TerminalTheme.monokai - theme.palette = (0..<TerminalTheme.extendedPaletteCount).map { - String(format: "#%06x", $0) - } - theme.cursorColorSemantic = .foreground - theme.cursorTextSemantic = .background - theme.selectionBackgroundSemantic = .foreground - theme.selectionForegroundSemantic = .background - - let directives = theme.ghosttyColorDirectives - - #expect(theme.isValid) - #expect(directives.contains("palette = 255=#0000ff")) - #expect(directives.contains("cursor-color = cell-foreground")) - #expect(directives.contains("cursor-text = cell-background")) - #expect(directives.contains("selection-background = cell-foreground")) - #expect(directives.contains("selection-foreground = cell-background")) - } - - @Test func ghosttyDirectivesNormalizeBareHexToCanonical() { - // A bare `rrggbb` (no `#`) still parses via rgbComponents, but the - // emitted directive must be canonical `#rrggbb` for the theme contract. - let theme = TerminalTheme( - background: "ff8000", - foreground: "#FDFFF1", - cursor: "#c0c1b5", - selectionBackground: "#57584f", - selectionForeground: "#fdfff1", - palette: Array(repeating: "aabbcc", count: TerminalTheme.paletteCount) - ) - let directives = theme.ghosttyColorDirectives - #expect(directives.contains("background = #ff8000")) - // Uppercase input is normalized to lowercase canonical form. - #expect(directives.contains("foreground = #fdfff1")) - #expect(directives.contains("palette = 0=#aabbcc")) - #expect(!directives.contains("background = ff8000")) - } - - @Test func decodedThemeCarriesCustomBoldColorIntoGhosttyConfig() throws { - let object = try #require( - JSONSerialization.jsonObject(with: JSONEncoder().encode(TerminalTheme.monokai)) as? [String: Any] - ) - var themeObject = object - themeObject["boldColor"] = "#4e2a84" - let data = try JSONSerialization.data(withJSONObject: themeObject) - - let theme = try JSONDecoder().decode(TerminalTheme.self, from: data) - - #expect(theme.ghosttyColorDirectives.contains("bold-color = #4e2a84")) - } - - @Test func decodedThemeCarriesBrightBoldColorIntoGhosttyConfig() throws { - let object = try #require( - JSONSerialization.jsonObject(with: JSONEncoder().encode(TerminalTheme.monokai)) as? [String: Any] - ) - var themeObject = object - themeObject["boldColor"] = "bright" - let data = try JSONSerialization.data(withJSONObject: themeObject) - - let theme = try JSONDecoder().decode(TerminalTheme.self, from: data) - - #expect(theme.ghosttyColorDirectives.contains("bold-color = bright")) - } - -} diff --git a/vendor/CmuxIrohTransport/PROVENANCE.md b/vendor/CmuxIrohTransport/PROVENANCE.md deleted file mode 100644 index 3f15b1b0..00000000 --- a/vendor/CmuxIrohTransport/PROVENANCE.md +++ /dev/null @@ -1,22 +0,0 @@ -# Provenance - -Vendored from the upstream `cmux` fork. Not a submodule, not a live SPM -dependency — a one-time source copy, following the `vendor/bonsplit` precedent. - -| | | -|---|---| -| Source repo | `upstream` remote (cmux) | -| Source path | `Packages/Shared/CmuxIrohTransport` | -| Commit | `34cc2ba5110adf45c27607e865be5867fbcad8a9` (`upstream/main`) | -| Extracted | 2026-07-27 | - -Re-extract with: - -```sh -git archive 34cc2ba5110adf45c27607e865be5867fbcad8a9 Packages/Shared/CmuxIrohTransport \ - | tar -x --strip-components=3 -C vendor/CmuxIrohTransport -``` - -There is no live tracking of upstream after this point. See -`plans/golden-tumbling-gray.md` for why (4,706-commit divergence, and upstream -commits risk reintroducing the account/broker coupling this port deliberately removes). diff --git a/vendor/CmuxIrohTransport/Package.resolved b/vendor/CmuxIrohTransport/Package.resolved deleted file mode 100644 index 682cedd0..00000000 --- a/vendor/CmuxIrohTransport/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "0d035151e0d1d0044ea0876c2c797a9555318623c2792fec2060b1ba376922d9", - "pins" : [ - { - "identity" : "iroh-ffi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/manaflow-ai/iroh-ffi.git", - "state" : { - "revision" : "6710783b85780186e8733bc016868a5867356275", - "version" : "1.0.2-cmux.3" - } - } - ], - "version" : 3 -} diff --git a/vendor/CmuxIrohTransport/Package.swift b/vendor/CmuxIrohTransport/Package.swift deleted file mode 100644 index c305d802..00000000 --- a/vendor/CmuxIrohTransport/Package.swift +++ /dev/null @@ -1,54 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "CmuxIrohTransport", - platforms: [ - .iOS(.v18), - .macOS(.v14), - ], - products: [ - .library( - name: "CmuxIrohTransport", - targets: ["CmuxIrohTransport"] - ), - ], - dependencies: [ - .package(path: "../CMUXMobileCore"), - .package( - url: "https://github.com/manaflow-ai/iroh-ffi.git", - exact: "1.0.2-cmux.3" - ), - ], - targets: [ - .target( - name: "CmuxIrohTransport", - dependencies: [ - "CMUXMobileCore", - .product(name: "IrohLib", package: "iroh-ffi"), - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault"), - ], - linkerSettings: [ - .linkedFramework("Security"), - ] - ), - .testTarget( - name: "CmuxIrohTransportTests", - dependencies: [ - "CmuxIrohTransport", - "CMUXMobileCore", - .product(name: "IrohLib", package: "iroh-ffi"), - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault"), - ] - ), - ] -) diff --git a/vendor/CmuxIrohTransport/README.md b/vendor/CmuxIrohTransport/README.md deleted file mode 100644 index 5a1ca5fd..00000000 --- a/vendor/CmuxIrohTransport/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# CmuxIrohTransport - -`CmuxIrohTransport` owns cmux's versioned Iroh application protocol. The package -is shared by macOS and iOS and keeps generated Iroh FFI handles behind injected -transport seams. - -The first bytes on every QUIC stream are a bounded binary header identifying -the lane. A connection begins with an authenticated control stream. Subsequent -server-event, terminal, and artifact streams reuse the authenticated QUIC -connection and retain independent cancellation and backpressure. - -Mac admission verifies signed authority and the live QUIC EndpointID before -broker traffic. First-time offline pairing also verifies and consumes its -one-use proof before discovery. Authenticated refreshes are coalesced and reused -for at most 30 seconds. Confirmed revocation closes only the affected -connection, while exact connectivity failure preserves local authority until -its signed expiry. Cached grants therefore retain a maximum seven-day -disconnected revoke window; first-pair sessions use the earlier of their two -one-day attestation expiries. - -Run the package behavior tests without launching either app: - -```sh -swift test --package-path Packages/Shared/CmuxIrohTransport -``` - -An admitted Mac connection gives one supervisor ownership of its control and -application-lane tasks. Injecting those operations keeps the lifetime policy -testable without an endpoint or app process: - -```swift -let supervisor = CmxIrohAdmittedConnectionSupervisor( - runControl: { await serveControl() }, - runApplicationLanes: { await serveApplicationLanes() }, - closeConnection: { await connection.close() }, - stopApplicationLanes: { await lanes.stop() } -) -await supervisor.run() -``` diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAccountRelayConfiguration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAccountRelayConfiguration.swift deleted file mode 100644 index 7c872092..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAccountRelayConfiguration.swift +++ /dev/null @@ -1,171 +0,0 @@ -/// Complete account-synchronized relay configuration. -/// -/// The active mode, dormant managed selection, and saved custom definitions -/// have independent lifecycles. Switching modes therefore never destroys the -/// configuration a user may switch back to later. -public struct CmxIrohAccountRelayConfiguration: Codable, Equatable, Sendable { - public enum Mode: String, Codable, Equatable, Sendable { - case automatic - case managed - case custom - } - - private enum CodingKeys: String, CodingKey { - case mode - case selectedManagedRelayIDs = "selectedManagedRelayIds" - case customRelays - } - - public let mode: Mode - public let selectedManagedRelayIDs: Set<String> - public let customRelays: [CmxIrohCustomRelayDefinition] - - public init( - mode: Mode, - selectedManagedRelayIDs: Set<String>, - customRelays: [CmxIrohCustomRelayDefinition] - ) throws { - guard selectedManagedRelayIDs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount, - selectedManagedRelayIDs.allSatisfy(Self.isSafeID), - customRelays.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount, - Set(customRelays.map(\.id)).count == customRelays.count, - Set(customRelays.map(\.url)).count == customRelays.count, - selectedManagedRelayIDs.isDisjoint(with: Set(customRelays.map(\.id))), - mode != .managed || !selectedManagedRelayIDs.isEmpty, - mode != .custom || !customRelays.isEmpty else { - throw CmxIrohRelayPolicyError.invalidSelection - } - self.mode = mode - self.selectedManagedRelayIDs = selectedManagedRelayIDs - self.customRelays = customRelays - } - - /// Safe empty configuration used before an account has saved a preference. - public static var automatic: Self { - Self( - validatedMode: .automatic, - selectedManagedRelayIDs: [], - customRelays: [] - ) - } - - public static func managed(_ relayIDs: Set<String>) throws -> Self { - try Self(mode: .managed, selectedManagedRelayIDs: relayIDs, customRelays: []) - } - - public static func custom(_ relays: [CmxIrohCustomRelayDefinition]) throws -> Self { - try Self(mode: .custom, selectedManagedRelayIDs: [], customRelays: relays) - } - - /// Active preference derived from the independent configuration fields. - public var activePreference: CmxIrohAccountRelayPreference { - switch mode { - case .automatic: - .automatic - case .managed: - .managed(selectedManagedRelayIDs) - case .custom: - .custom(customRelays) - } - } - - /// Replaces only the active mode or managed selection. - public func updatingActivePreference( - _ preference: CmxIrohAccountRelayPreference - ) throws -> Self { - switch preference { - case .automatic: - try Self( - mode: .automatic, - selectedManagedRelayIDs: selectedManagedRelayIDs, - customRelays: customRelays - ) - case let .managed(relayIDs): - try Self( - mode: .managed, - selectedManagedRelayIDs: relayIDs, - customRelays: customRelays - ) - case .custom: - try Self( - mode: .custom, - selectedManagedRelayIDs: selectedManagedRelayIDs, - customRelays: customRelays - ) - } - } - - /// Replaces saved custom metadata without implicitly changing active mode. - /// Removing the final active custom relay safely returns to automatic mode. - public func replacingCustomRelays( - _ relays: [CmxIrohCustomRelayDefinition] - ) throws -> Self { - try Self( - mode: mode == .custom && relays.isEmpty ? .automatic : mode, - selectedManagedRelayIDs: selectedManagedRelayIDs, - customRelays: relays - ) - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let mode = try container.decode(Mode.self, forKey: .mode) - let orderedManagedIDs = try container.decodeIfPresent( - [String].self, - forKey: .selectedManagedRelayIDs - ) ?? [] - guard Set(orderedManagedIDs).count == orderedManagedIDs.count else { - throw DecodingError.dataCorruptedError( - forKey: .selectedManagedRelayIDs, - in: container, - debugDescription: "Duplicate managed relay identifier" - ) - } - do { - try self.init( - mode: mode, - selectedManagedRelayIDs: Set(orderedManagedIDs), - customRelays: container.decodeIfPresent( - [CmxIrohCustomRelayDefinition].self, - forKey: .customRelays - ) ?? [] - ) - } catch { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid relay configuration") - ) - } - } - - public func encode(to encoder: any Encoder) throws { - _ = try Self( - mode: mode, - selectedManagedRelayIDs: selectedManagedRelayIDs, - customRelays: customRelays - ) - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(mode, forKey: .mode) - try container.encode(selectedManagedRelayIDs.sorted(), forKey: .selectedManagedRelayIDs) - try container.encode(customRelays, forKey: .customRelays) - } - - private static func isSafeID(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } - - private init( - validatedMode mode: Mode, - selectedManagedRelayIDs: Set<String>, - customRelays: [CmxIrohCustomRelayDefinition] - ) { - self.mode = mode - self.selectedManagedRelayIDs = selectedManagedRelayIDs - self.customRelays = customRelays - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAccountRelayPreference.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAccountRelayPreference.swift deleted file mode 100644 index fcfb47d1..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAccountRelayPreference.swift +++ /dev/null @@ -1,101 +0,0 @@ -/// Account-synchronized preference for managed or user-defined relays. -public enum CmxIrohAccountRelayPreference: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case mode - case relayIDs = "selectedManagedRelayIds" - case relays = "customRelays" - } - - private enum Mode: String, Codable { - case automatic - case managed - case custom - } - - /// Allow every relay authorized by the latest verified managed policy. - case automatic - - /// Allow only the listed stable identifiers from the managed policy. - case managed(Set<String>) - - /// Use only the listed custom relays, with no managed-provider fallback. - case custom([CmxIrohCustomRelayDefinition]) - - /// Decodes and validates one account preference. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - switch try container.decode(Mode.self, forKey: .mode) { - case .automatic: - self = .automatic - case .managed: - let orderedIDs = try container.decode([String].self, forKey: .relayIDs) - let ids = Set(orderedIDs) - guard ids.count == orderedIDs.count, Self.isValidManagedIDs(ids) else { - throw DecodingError.dataCorruptedError( - forKey: .relayIDs, - in: container, - debugDescription: "Invalid managed relay selection" - ) - } - self = .managed(ids) - case .custom: - let relays = try container.decode([CmxIrohCustomRelayDefinition].self, forKey: .relays) - guard Self.isValidCustomRelays(relays) else { - throw DecodingError.dataCorruptedError( - forKey: .relays, - in: container, - debugDescription: "Invalid custom relay selection" - ) - } - self = .custom(relays) - } - } - - /// Encodes the canonical broker preference schema. - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .automatic: - try container.encode(Mode.automatic, forKey: .mode) - case let .managed(ids): - guard Self.isValidManagedIDs(ids) else { - throw EncodingError.invalidValue( - self, - .init(codingPath: encoder.codingPath, debugDescription: "Invalid managed selection") - ) - } - try container.encode(Mode.managed, forKey: .mode) - try container.encode(ids.sorted(), forKey: .relayIDs) - case let .custom(relays): - guard Self.isValidCustomRelays(relays) else { - throw EncodingError.invalidValue( - self, - .init(codingPath: encoder.codingPath, debugDescription: "Invalid custom selection") - ) - } - try container.encode(Mode.custom, forKey: .mode) - try container.encode(relays, forKey: .relays) - } - } - - private static func isValidManagedIDs(_ ids: Set<String>) -> Bool { - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(ids.count) - && ids.allSatisfy(isSafeID) - } - - private static func isValidCustomRelays(_ relays: [CmxIrohCustomRelayDefinition]) -> Bool { - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(relays.count) - && Set(relays.map(\.id)).count == relays.count - && Set(relays.map(\.url)).count == relays.count - } - - private static func isSafeID(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohActiveBindingConnectionQuota.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohActiveBindingConnectionQuota.swift deleted file mode 100644 index afb9a8de..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohActiveBindingConnectionQuota.swift +++ /dev/null @@ -1,38 +0,0 @@ -/// Admission policy for active Iroh sessions owned by one broker binding. -/// -/// The host keeps the authoritative connection collection. This value only -/// evaluates that collection, so quota state cannot drift when a connection is -/// closed, revoked, or removed in bulk. -public struct CmxIrohActiveBindingConnectionQuota: Sendable { - /// Two sessions permit a live client to overlap its replacement connection - /// during route migration or reconnect without monopolizing the host pool. - public static let recommendedMaximumActiveConnectionsPerBinding = 2 - - public let maximumActiveConnectionsPerBinding: Int - - public init( - maximumActiveConnectionsPerBinding: Int = Self - .recommendedMaximumActiveConnectionsPerBinding - ) { - precondition(maximumActiveConnectionsPerBinding > 0) - self.maximumActiveConnectionsPerBinding = maximumActiveConnectionsPerBinding - } - - /// Returns whether one more session for `bindingID` fits within the quota. - /// - /// The caller must evaluate and insert while holding the same synchronization - /// boundary so concurrent admissions cannot both consume the final slot. - public func allowsAdmission<ActiveBindingIDs: Sequence>( - for bindingID: String, - activeBindingIDs: ActiveBindingIDs - ) -> Bool where ActiveBindingIDs.Element == String { - var matchingConnectionCount = 0 - for activeBindingID in activeBindingIDs where activeBindingID == bindingID { - matchingConnectionCount += 1 - if matchingConnectionCount >= maximumActiveConnectionsPerBinding { - return false - } - } - return true - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAckCodec.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAckCodec.swift deleted file mode 100644 index 10c6dea8..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAckCodec.swift +++ /dev/null @@ -1,128 +0,0 @@ -public import Foundation - -/// Encodes fixed eight-byte frames for the acknowledged admission barrier. -public struct CmxIrohAdmissionAckCodec: Sendable { - /// The exact number of bytes consumed by every admission frame. - public static let frameByteCount = 8 - - private static let magic = Data("CMXA".utf8) - private static let version: UInt8 = 1 - - /// Creates an admission-frame codec. - public init() {} - - /// Encodes the server's initial admission decision. - /// - /// - Parameter decision: The accepted or coded-denial result. - /// - Returns: Exactly ``frameByteCount`` bytes. - public func encode(_ decision: CmxIrohAdmissionDecision) -> Data { - let frame: CmxIrohAdmissionFrame = switch decision { - case .accepted: - .acceptedPendingNatTraversal - case let .denied(code): - .denied(code: code) - } - return encodeFrame(frame) - } - - /// Encodes one admission-barrier frame. - /// - /// - Parameter frame: The role-specific admission frame. - /// - Returns: Exactly ``frameByteCount`` bytes. - public func encodeFrame(_ frame: CmxIrohAdmissionFrame) -> Data { - let status: UInt8 - let code: UInt16 - switch frame { - case .acceptedPendingNatTraversal: - status = 0 - code = 0 - case .acceptedRelayOnly: - status = 4 - code = 0 - case let .denied(denialCode): - status = 1 - code = denialCode - case .clientReady: - status = 2 - code = 0 - case .serverReady: - status = 3 - code = 0 - } - var frame = Self.magic - frame.append(Self.version) - frame.append(status) - let bigEndian = code.bigEndian - withUnsafeBytes(of: bigEndian) { frame.append(contentsOf: $0) } - return frame - } - - /// Decodes the first complete server decision. - /// - /// - Parameter data: Bytes beginning at the server decision. - /// - Returns: The validated decision. - /// - Throws: ``CmxIrohAdmissionAckCodecError`` for malformed input. - public func decodePrefix(_ data: Data) throws -> CmxIrohAdmissionDecision { - switch try decodeFramePrefix(data) { - case .acceptedPendingNatTraversal, .acceptedRelayOnly: - return .accepted - case let .denied(code): - return .denied(code: code) - case let frame: - throw CmxIrohAdmissionAckCodecError.invalidDecisionFrame(frame) - } - } - - /// Decodes the first complete role-specific admission frame. - /// - /// - Parameter data: Bytes beginning at the admission frame. - /// - Returns: The validated frame. - /// - Throws: ``CmxIrohAdmissionAckCodecError`` for malformed input. - public func decodeFramePrefix(_ data: Data) throws -> CmxIrohAdmissionFrame { - guard data.count >= Self.frameByteCount else { - throw CmxIrohAdmissionAckCodecError.incompleteFrame - } - var cursor = CmxIrohBinaryCursor(data: data.prefix(Self.frameByteCount)) - guard try cursor.readData(byteCount: Self.magic.count) == Self.magic else { - throw CmxIrohAdmissionAckCodecError.invalidMagic - } - let version = try cursor.readUInt8() - guard version == Self.version else { - throw CmxIrohAdmissionAckCodecError.unsupportedVersion(version) - } - let status = try cursor.readUInt8() - let code = try cursor.readUInt16() - switch status { - case 0: - guard code == 0 else { - throw CmxIrohAdmissionAckCodecError.invalidAcceptedCode(code) - } - return .acceptedPendingNatTraversal - case 1: - return .denied(code: code) - case 2: - guard code == 0 else { - throw CmxIrohAdmissionAckCodecError.invalidReadyCode( - status: status, - code: code - ) - } - return .clientReady - case 3: - guard code == 0 else { - throw CmxIrohAdmissionAckCodecError.invalidReadyCode( - status: status, - code: code - ) - } - return .serverReady - case 4: - guard code == 0 else { - throw CmxIrohAdmissionAckCodecError.invalidAcceptedCode(code) - } - return .acceptedRelayOnly - default: - throw CmxIrohAdmissionAckCodecError.invalidStatus(status) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAckCodecError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAckCodecError.swift deleted file mode 100644 index 5853c565..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAckCodecError.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Binary framing failures for a control-stream admission frame. -public enum CmxIrohAdmissionAckCodecError: Error, Equatable, Sendable { - /// Fewer than eight response bytes are available. - case incompleteFrame - - /// The response did not begin with the cmux admission marker. - case invalidMagic - - /// The response version is unsupported. - case unsupportedVersion(UInt8) - - /// The response status discriminator is unknown. - case invalidStatus(UInt8) - - /// An accepted response carried a nonzero denial code. - case invalidAcceptedCode(UInt16) - - /// A ready frame carried a nonzero code. - case invalidReadyCode(status: UInt8, code: UInt16) - - /// A ready frame appeared where an initial server decision was required. - case invalidDecisionFrame(CmxIrohAdmissionFrame) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorization.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorization.swift deleted file mode 100644 index 86db4f6c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorization.swift +++ /dev/null @@ -1,19 +0,0 @@ -/// Local authorization result for an authenticated Iroh connection. -public enum CmxIrohAdmissionAuthorization: Equatable, Sendable { - /// The exact TLS-bound iOS binding may use the application transport. - case accepted( - CmxIrohAdmittedPeer, - onlineLease: CmxIrohOnlineAdmissionLease? - ) - /// Admission failed with a non-sensitive protocol code. - case denied(code: UInt16) - - var wireDecision: CmxIrohAdmissionDecision { - switch self { - case .accepted: - .accepted - case let .denied(code): - .denied(code: code) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift deleted file mode 100644 index 0935875b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionAuthorizing.swift +++ /dev/null @@ -1,9 +0,0 @@ -public import CMUXMobileCore - -/// Fail-closed authorization seam for the first control stream on a connection. -public protocol CmxIrohAdmissionAuthorizing: Sendable { - func authorize( - credential: CmxIrohAdmissionCredential, - authenticatedPeerID: CmxIrohPeerIdentity - ) async -> CmxIrohAdmissionAuthorization -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift deleted file mode 100644 index 2476f367..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionController.swift +++ /dev/null @@ -1,127 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Mac admission policy combining online grants, offline sessions, and local revoke state. -public actor CmxIrohAdmissionController: CmxIrohAdmissionAuthorizing { - private let offlineSessions: CmxIrohOfflinePairingSessions - private let onlineRegistry: CmxIrohOnlineAdmissionRegistry - private let now: @Sendable () -> Date - private var acceptor: CmxIrohGrantPeer - private var pairingEnabled: Bool - private var revokedBindingIDs: Set<String> = [] - private var policyRevision: UInt64 = 0 - private var policyMutationCount = 0 - - public init( - acceptor: CmxIrohGrantPeer, - pairingEnabled: Bool, - offlineSessions: CmxIrohOfflinePairingSessions, - onlineRegistry: CmxIrohOnlineAdmissionRegistry, - now: @escaping @Sendable () -> Date = { Date() } - ) { - self.acceptor = acceptor - self.pairingEnabled = pairingEnabled - self.offlineSessions = offlineSessions - self.onlineRegistry = onlineRegistry - self.now = now - } - - /// Atomically replaces authenticated broker policy after a registry refresh. - public func update( - keys: CmxIrohGrantVerificationKeySet, - acceptor: CmxIrohGrantPeer, - pairingEnabled: Bool - ) async { - beginPolicyMutation() - defer { endPolicyMutation() } - await onlineRegistry.update(keys: keys, acceptor: acceptor) - await offlineSessions.setPairingEnabled(pairingEnabled) - self.acceptor = acceptor - self.pairingEnabled = pairingEnabled - } - - /// Replaces the root-verified managed fleet without restarting admission. - func updateManagedRelayURLs(_ relayURLs: Set<String>) async { - beginPolicyMutation() - defer { endPolicyMutation() } - await onlineRegistry.updateManagedRelayURLs(relayURLs) - } - - /// Applies local revoke before the backend round trip completes. - public func revoke(bindingID: String) async { - beginPolicyMutation() - defer { endPolicyMutation() } - revokedBindingIDs.insert(bindingID) - await offlineSessions.revoke(bindingID: bindingID) - await onlineRegistry.revoke(bindingID: bindingID) - } - - public func authorize( - credential: CmxIrohAdmissionCredential, - authenticatedPeerID: CmxIrohPeerIdentity - ) async -> CmxIrohAdmissionAuthorization { - guard policyMutationCount == 0, - pairingEnabled, - acceptor.platform == .mac, - !revokedBindingIDs.contains(acceptor.bindingID) else { - return .denied(code: 1) - } - let revision = policyRevision - do { - switch credential.kind { - case .pairGrant: - guard let token = credential.pairGrantToken else { - return .denied(code: 1) - } - switch await onlineRegistry.authorizePairGrant( - token, - authenticatedPeerID: authenticatedPeerID - ) { - case let .accepted(lease): - return checkedAuthorization(lease, revision: revision) - case .denied: - return .denied(code: 1) - } - case .offlinePairing: - let pair = try await offlineSessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: authenticatedPeerID, - now: now() - ) - guard policyMutationCount == 0, policyRevision == revision else { - return .denied(code: 1) - } - switch await onlineRegistry.authorizeOfflinePair(pair) { - case let .accepted(lease): - return checkedAuthorization(lease, revision: revision) - case .denied: - return .denied(code: 1) - } - } - } catch { - return .denied(code: 1) - } - } - - private func checkedAuthorization( - _ lease: CmxIrohOnlineAdmissionLease, - revision: UInt64 - ) -> CmxIrohAdmissionAuthorization { - guard policyMutationCount == 0, - policyRevision == revision, - !revokedBindingIDs.contains(lease.peer.bindingID), - !revokedBindingIDs.contains(acceptor.bindingID) else { - return .denied(code: 1) - } - return .accepted(lease.peer, onlineLease: lease) - } - - private func beginPolicyMutation() { - policyRevision &+= 1 - policyMutationCount += 1 - } - - private func endPolicyMutation() { - policyMutationCount -= 1 - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredential.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredential.swift deleted file mode 100644 index 1d8f8166..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredential.swift +++ /dev/null @@ -1,100 +0,0 @@ -public import Foundation - -/// A bounded admission proof sent only on the first control stream. -public struct CmxIrohAdmissionCredential: Equatable, Sendable { - /// The credential's validation path. - public let kind: CmxIrohAdmissionCredentialKind - - /// The backend-signed pair grant for ``CmxIrohAdmissionCredentialKind/pairGrant``. - public let pairGrantToken: String? - - /// The caller's backend-signed endpoint attestation for offline pairing. - public let endpointAttestation: String? - - /// The one-use invitation selected by a local pairing QR. - public let invitationID: CmxIrohResourceID? - - /// The 32-byte proof derived from the invitation secret and both EndpointIDs. - public let offlineProof: Data? - - private init( - kind: CmxIrohAdmissionCredentialKind, - pairGrantToken: String?, - endpointAttestation: String?, - invitationID: CmxIrohResourceID?, - offlineProof: Data? - ) { - self.kind = kind - self.pairGrantToken = pairGrantToken - self.endpointAttestation = endpointAttestation - self.invitationID = invitationID - self.offlineProof = offlineProof - } - - /// Creates a credential from a backend-signed pair grant. - /// - /// - Parameter token: A compact EdDSA JWS no larger than 12 KiB. - /// - Returns: A validated pair-grant credential. - /// - Throws: ``CmxIrohAdmissionCredentialError/invalidSignedToken`` for malformed input. - public static func pairGrant(_ token: String) throws -> CmxIrohAdmissionCredential { - guard Self.isValidCompactJWS(token) else { - throw CmxIrohAdmissionCredentialError.invalidSignedToken - } - return CmxIrohAdmissionCredential( - kind: .pairGrant, - pairGrantToken: token, - endpointAttestation: nil, - invitationID: nil, - offlineProof: nil - ) - } - - /// Creates a first-pair credential that preserves same-account authorization offline. - /// - /// QR possession supplies the one-use invitation. The endpoint attestation - /// independently proves the caller's cached Stack account binding. - /// - /// - Parameters: - /// - endpointAttestation: A compact backend-signed endpoint-attestation JWS. - /// - invitationID: The opaque one-use invitation identifier from the QR. - /// - proof: A 32-byte proof bound to both EndpointIDs. - /// - Returns: A validated offline-pairing credential. - /// - Throws: ``CmxIrohAdmissionCredentialError`` when a field is malformed. - public static func offlinePairing( - endpointAttestation: String, - invitationID: CmxIrohResourceID, - proof: Data - ) throws -> CmxIrohAdmissionCredential { - guard Self.isValidCompactJWS(endpointAttestation) else { - throw CmxIrohAdmissionCredentialError.invalidSignedToken - } - guard proof.count == 32 else { - throw CmxIrohAdmissionCredentialError.invalidOfflineProofLength(proof.count) - } - return CmxIrohAdmissionCredential( - kind: .offlinePairing, - pairGrantToken: nil, - endpointAttestation: endpointAttestation, - invitationID: invitationID, - offlineProof: proof - ) - } - - private static func isValidCompactJWS(_ value: String) -> Bool { - let bytes = Array(value.utf8) - guard (5 ... 12 * 1_024).contains(bytes.count) else { return false } - let segments = value.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count == 3, segments.allSatisfy({ !$0.isEmpty }) else { return false } - return segments.joined().utf8.allSatisfy { byte in - switch byte { - case UInt8(ascii: "A") ... UInt8(ascii: "Z"), - UInt8(ascii: "a") ... UInt8(ascii: "z"), - UInt8(ascii: "0") ... UInt8(ascii: "9"), - UInt8(ascii: "_"), UInt8(ascii: "-"): - true - default: - false - } - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredentialError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredentialError.swift deleted file mode 100644 index ea9c8864..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredentialError.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// Validation failures for Iroh admission credentials. -public enum CmxIrohAdmissionCredentialError: Error, Equatable, Sendable { - /// A compact JWS is missing, malformed, or exceeds its wire limit. - case invalidSignedToken - - /// The offline proof must contain exactly 32 bytes. - case invalidOfflineProofLength(Int) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredentialKind.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredentialKind.swift deleted file mode 100644 index d0d232a2..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionCredentialKind.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// The proof used to admit an Iroh control stream. -public enum CmxIrohAdmissionCredentialKind: Equatable, Sendable { - /// A backend-signed grant binding the two exact EndpointIDs. - case pairGrant - - /// Cached same-account endpoint attestation plus a one-use local invitation. - case offlinePairing -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionDecision.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionDecision.swift deleted file mode 100644 index c3d75cd8..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionDecision.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// The server's initial response to a control-stream admission proof. -public enum CmxIrohAdmissionDecision: Equatable, Sendable { - /// The proof passed, but application lanes await the NAT authorization barrier. - case accepted - - /// Admission failed with a non-sensitive protocol code. - case denied(code: UInt16) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionFrame.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionFrame.swift deleted file mode 100644 index ec8f65f5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmissionFrame.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// One fixed-size control frame in the acknowledged admission barrier. -public enum CmxIrohAdmissionFrame: Equatable, Sendable { - /// The server accepted the credential, but NAT traversal remains gated. - case acceptedPendingNatTraversal - - /// The server accepted the credential and intentionally keeps this - /// connection relay-bound without authorizing NAT traversal. - case acceptedRelayOnly - - /// The server denied admission with a non-sensitive protocol code. - case denied(code: UInt16) - - /// The client authorized NAT traversal on its exact connection. - case clientReady - - /// The server authorized NAT traversal and is ready for application lanes. - case serverReady -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedConnectionSupervisor.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedConnectionSupervisor.swift deleted file mode 100644 index 74fe0b53..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedConnectionSupervisor.swift +++ /dev/null @@ -1,69 +0,0 @@ -/// Owns the coupled control and application-lane lifetime of one admitted connection. -/// -/// Construct one supervisor per admitted peer. The first child operation to -/// finish, or cancellation of ``run()``, cancels the sibling before the -/// connection and application lanes are closed in a stable order. Repeated -/// calls to ``run()`` are ignored so cleanup cannot run twice for one owner. -/// -/// ```swift -/// let supervisor = CmxIrohAdmittedConnectionSupervisor( -/// runControl: { await serveControl() }, -/// runApplicationLanes: { await serveApplicationLanes() }, -/// closeConnection: { await connection.close() }, -/// stopApplicationLanes: { await lanes.stop() } -/// ) -/// await supervisor.run() -/// ``` -public actor CmxIrohAdmittedConnectionSupervisor { - private let runControl: @Sendable () async -> Void - private let runApplicationLanes: @Sendable () async -> Void - private let closeConnection: @Sendable () async -> Void - private let stopApplicationLanes: @Sendable () async -> Void - private var didRun = false - - /// Creates the sole lifetime owner for one admitted connection. - /// - /// - Parameters: - /// - runControl: Serves the authenticated control protocol until it ends - /// or is cancelled. - /// - runApplicationLanes: Accepts and serves post-admission application - /// lanes until it ends or is cancelled. - /// - closeConnection: Closes the complete peer connection and unblocks - /// outstanding stream operations. - /// - stopApplicationLanes: Cancels and joins every accepted application - /// lane after the connection starts closing. - public init( - runControl: @escaping @Sendable () async -> Void, - runApplicationLanes: @escaping @Sendable () async -> Void, - closeConnection: @escaping @Sendable () async -> Void, - stopApplicationLanes: @escaping @Sendable () async -> Void - ) { - self.runControl = runControl - self.runApplicationLanes = runApplicationLanes - self.closeConnection = closeConnection - self.stopApplicationLanes = stopApplicationLanes - } - - /// Runs the connection until either child exits, then closes all owned work. - public func run() async { - guard !didRun else { return } - didRun = true - let runControl = runControl - let runApplicationLanes = runApplicationLanes - let closeConnection = closeConnection - let stopApplicationLanes = stopApplicationLanes - - await withTaskGroup(of: Void.self) { group in - group.addTask { - await runControl() - } - group.addTask { - await runApplicationLanes() - } - _ = await group.next() - group.cancelAll() - await closeConnection() - await stopApplicationLanes() - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedPeer.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedPeer.swift deleted file mode 100644 index 6f9c3d5d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedPeer.swift +++ /dev/null @@ -1,47 +0,0 @@ -public import CMUXMobileCore - -/// The exact iOS binding admitted by the Mac after TLS and grant verification. -public struct CmxIrohAdmittedPeer: Equatable, Sendable { - public let bindingID: String - public let deviceID: String - public let endpointID: CmxIrohPeerIdentity - public let identityGeneration: Int - public let platform: CmxIrohPlatform - - init( - bindingID: String, - deviceID: String, - endpointID: CmxIrohPeerIdentity, - identityGeneration: Int, - platform: CmxIrohPlatform - ) { - self.bindingID = bindingID - self.deviceID = deviceID - self.endpointID = endpointID - self.identityGeneration = identityGeneration - self.platform = platform - } - - /// Copies a peer tuple that a verifier has already authenticated. - /// Construction alone does not grant access; server admission also binds - /// this tuple to the live QUIC TLS identity before exposing it to the host. - public init(peer: CmxIrohGrantPeer) { - self.init( - bindingID: peer.bindingID, - deviceID: peer.deviceID, - endpointID: peer.endpointID, - identityGeneration: peer.identityGeneration, - platform: peer.platform - ) - } - - init(attestation: CmxIrohEndpointAttestationClaims) { - self.init( - bindingID: attestation.bindingID, - deviceID: attestation.deviceID, - endpointID: attestation.endpointID, - identityGeneration: attestation.identityGeneration, - platform: attestation.platform - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedServerSession.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedServerSession.swift deleted file mode 100644 index 7c7c2708..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAdmittedServerSession.swift +++ /dev/null @@ -1,47 +0,0 @@ -public import CMUXMobileCore - -/// One authenticated iOS peer connection exposed to the Mac application layer. -/// -/// The control transport preserves the existing mobile RPC protocol while the -/// lane methods expose independent terminal, event, and artifact streams on the -/// same admitted QUIC connection. Only ``CmxIrohHostRuntime`` constructs this -/// value, after binding the admission credential to the live TLS EndpointID. -public struct CmxIrohAdmittedServerSession: Sendable { - /// The exact iOS binding authenticated for this connection. - public let peer: CmxIrohAdmittedPeer - - /// The existing mobile RPC byte stream on the connection's control lane. - public let controlTransport: any CmxByteTransport - - private let session: CmxIrohServerSession - - init( - peer: CmxIrohAdmittedPeer, - session: CmxIrohServerSession - ) { - self.peer = peer - self.session = session - controlTransport = CmxIrohServerByteTransport(session: session) - } - - /// Accepts one client-created terminal or artifact lane. - public func acceptBidirectionalLane() async throws -> ( - lane: CmxIrohLane, - stream: CmxIrohBidirectionalStream - ) { - try await session.acceptBidirectionalLane() - } - - /// Opens one server-event or artifact lane to the admitted iOS peer. - public func openSendLane( - _ lane: CmxIrohLane, - priority: Int32 - ) async throws -> any CmxIrohSendStream { - try await session.openSendLane(lane, priority: priority) - } - - /// Closes the complete peer connection and every child stream. - public func close() async { - await session.close() - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAppInstanceRepository.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAppInstanceRepository.swift deleted file mode 100644 index fa3cf940..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohAppInstanceRepository.swift +++ /dev/null @@ -1,66 +0,0 @@ -import CryptoKit -public import Foundation - -/// Persists the broker-facing app-instance UUID for one active account and tag. -public actor CmxIrohAppInstanceRepository { - private static let activeScopeKey = "cmux.iroh.app-instance.scope.v1" - private static let identifierKey = "cmux.iroh.app-instance.id.v1" - - private let store: any CmxIrohInstallStateStoring - private let makeUUID: @Sendable () -> UUID - - public init( - store: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore(), - makeUUID: @escaping @Sendable () -> UUID = { UUID() } - ) { - self.store = store - self.makeUUID = makeUUID - } - - /// Returns a stable lowercase UUID and rotates it when account or tag changes. - public func appInstanceID(accountID: String, tag: String) throws -> String { - guard !accountID.isEmpty, - accountID.utf8.count <= 1_024, - Self.isSafeTag(tag) else { - throw CmxIrohIdentityRepositoryError.invalidScope - } - let scope = Self.scope(accountID: accountID, tag: tag) - if store.string(forKey: Self.activeScopeKey) == scope, - let existing = store.string(forKey: Self.identifierKey), - Self.isCanonicalUUID(existing) { - return existing - } - let identifier = makeUUID().uuidString.lowercased() - guard Self.isCanonicalUUID(identifier) else { - throw CmxIrohIdentityRepositoryError.randomGenerationFailed(-1) - } - store.set(scope, forKey: Self.activeScopeKey) - store.set(identifier, forKey: Self.identifierKey) - return identifier - } - - /// Removes the active app instance during sign-out or local revocation. - public func deactivate() { - store.set(nil, forKey: Self.activeScopeKey) - store.set(nil, forKey: Self.identifierKey) - } - - private static func scope(accountID: String, tag: String) -> String { - let transcript = Data("cmux/iroh/app-instance-scope/v1\0\(accountID)\0\(tag)".utf8) - return SHA256.hash(data: transcript).map { String(format: "%02x", $0) }.joined() - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func isSafeTag(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBidirectionalStream.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBidirectionalStream.swift deleted file mode 100644 index 259d4b8a..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBidirectionalStream.swift +++ /dev/null @@ -1,21 +0,0 @@ -/// The independently readable and writable halves of one bidirectional stream. -public struct CmxIrohBidirectionalStream: Sendable { - /// The peer-to-local stream half. - public let receiveStream: any CmxIrohReceiveStream - - /// The local-to-peer stream half. - public let sendStream: any CmxIrohSendStream - - /// Creates a bidirectional stream pair. - /// - /// - Parameters: - /// - receiveStream: The readable half. - /// - sendStream: The writable half. - public init( - receiveStream: any CmxIrohReceiveStream, - sendStream: any CmxIrohSendStream - ) { - self.receiveStream = receiveStream - self.sendStream = sendStream - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBinaryCursor.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBinaryCursor.swift deleted file mode 100644 index febc59ae..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBinaryCursor.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -/// Bounds-checked reader for one small Iroh stream-header payload. -struct CmxIrohBinaryCursor { - private let data: Data - private(set) var offset: Int = 0 - - init(data: Data) { - self.data = data - } - - var remainingByteCount: Int { - data.count - offset - } - - mutating func readUInt8() throws -> UInt8 { - let bytes = try readData(byteCount: 1) - return bytes[bytes.startIndex] - } - - mutating func readUInt16() throws -> UInt16 { - let bytes = try readData(byteCount: 2) - return bytes.reduce(UInt16.zero) { partial, byte in - (partial << 8) | UInt16(byte) - } - } - - mutating func readUInt32() throws -> UInt32 { - let bytes = try readData(byteCount: 4) - return bytes.reduce(UInt32.zero) { partial, byte in - (partial << 8) | UInt32(byte) - } - } - - mutating func readUInt64() throws -> UInt64 { - let bytes = try readData(byteCount: 8) - return bytes.reduce(UInt64.zero) { partial, byte in - (partial << 8) | UInt64(byte) - } - } - - mutating func readData(byteCount: Int) throws -> Data { - guard byteCount >= 0, byteCount <= remainingByteCount else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - let start = data.index(data.startIndex, offsetBy: offset) - let end = data.index(start, offsetBy: byteCount) - offset += byteCount - return data[start ..< end] - } - - mutating func readString(byteCount: Int) throws -> String { - let bytes = try readData(byteCount: byteCount) - guard let value = String(data: bytes, encoding: .utf8) else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - return value - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindAddress.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindAddress.swift deleted file mode 100644 index b2c689e3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindAddress.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Darwin - -/// A numeric IP socket address used for a required Iroh UDP bind. -public struct CmxIrohBindAddress: Equatable, Sendable { - /// The unbracketed IPv4 or IPv6 literal. - public let ipAddress: String - - /// The stable, nonzero UDP port. - public let port: UInt16 - - let socketAddress: String - - /// Creates a validated stable bind address. - /// - /// Host names and scoped IPv6 literals are intentionally unsupported because - /// the Iroh FFI parses this value as Rust's numeric `SocketAddr`. - /// - /// - Parameters: - /// - ipAddress: An unbracketed numeric IPv4 or IPv6 literal. - /// - port: A nonzero UDP port. - /// - Throws: ``CmxIrohBindAddressError`` for unsupported input. - public init( - ipAddress: String, - port: UInt16 - ) throws { - guard port != 0 else { - throw CmxIrohBindAddressError.zeroPort - } - let bytes = Array(ipAddress.utf8) - guard (1 ... 64).contains(bytes.count), - bytes.allSatisfy({ byte in - (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte) - || (UInt8(ascii: "a") ... UInt8(ascii: "f")).contains(byte) - || (UInt8(ascii: "A") ... UInt8(ascii: "F")).contains(byte) - || byte == UInt8(ascii: ".") - || byte == UInt8(ascii: ":") - }) - else { - throw CmxIrohBindAddressError.invalidIPAddress - } - - var ipv4 = in_addr() - let isIPv4 = ipAddress.withCString { - inet_pton(AF_INET, $0, &ipv4) == 1 - } - if isIPv4 { - self.ipAddress = ipAddress - self.port = port - socketAddress = "\(ipAddress):\(port)" - return - } - - var ipv6 = in6_addr() - let isIPv6 = ipAddress.withCString { - inet_pton(AF_INET6, $0, &ipv6) == 1 - } - guard isIPv6 else { - throw CmxIrohBindAddressError.invalidIPAddress - } - self.ipAddress = ipAddress - self.port = port - socketAddress = "[\(ipAddress)]:\(port)" - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindAddressError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindAddressError.swift deleted file mode 100644 index 3cd52569..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindAddressError.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// Validation failures for a stable Iroh UDP bind address. -public enum CmxIrohBindAddressError: Error, Equatable, Sendable { - /// The host is not an unbracketed numeric IPv4 or IPv6 literal. - case invalidIPAddress - - /// Port zero belongs to the endpoint's ephemeral bind policy. - case zeroPort -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindingRevoking.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindingRevoking.swift deleted file mode 100644 index 62dde241..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBindingRevoking.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// Broker capability for idempotently revoking an account-owned binding. -public protocol CmxIrohBindingRevoking: Sendable { - /// Revokes one binding after authenticating its owning account. - /// - /// Repeating a confirmed request for the same binding must remain safe. - /// - /// - Parameter bindingID: The broker-owned lowercase binding UUID. - func revoke(bindingID: String) async throws -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBonjour.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBonjour.swift deleted file mode 100644 index 9ba0cdfb..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBonjour.swift +++ /dev/null @@ -1,36 +0,0 @@ -/// Stable identity for one interface-scoped Bonjour result. -public struct CmxIrohBonjourServiceID: Equatable, Hashable, Sendable { - public let serviceName: String - public let interfaceIndex: UInt32 - - public init(serviceName: String, interfaceIndex: UInt32) { - self.serviceName = serviceName - self.interfaceIndex = interfaceIndex - } -} - -public enum CmxIrohBonjourPublisherEvent: Equatable, Sendable { - case registered(CmxIrohBonjourServiceID) - case policyDenied - case failed(Int32) -} - -public enum CmxIrohBonjourBrowserEvent: Equatable, Sendable { - case resolved(CmxIrohBonjourServiceID, CmxIrohBonjourResolvedService) - case removed(CmxIrohBonjourServiceID) - case policyDenied - case failed(Int32) -} - -/// Replaces all interface-scoped registrations atomically from the caller's view. -public protocol CmxIrohBonjourPublishing: Sendable { - func events() async -> AsyncStream<CmxIrohBonjourPublisherEvent> - func replace(with advertisements: [CmxIrohLANAdvertisement]) async throws - func stop() async -} - -/// Browses only the declared cmux Iroh service and reports resolved TXT records. -public protocol CmxIrohBonjourBrowsing: Sendable { - func events() async -> AsyncStream<CmxIrohBonjourBrowserEvent> - func stop() async -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBindingMetadata.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBindingMetadata.swift deleted file mode 100644 index 1dc37374..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBindingMetadata.swift +++ /dev/null @@ -1,116 +0,0 @@ -public import CMUXMobileCore -import Foundation - -/// The exact broker binding tuple needed to recover one registered endpoint. -public struct CmxIrohBrokerBindingMetadata: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case bindingID - case deviceID - case appInstanceID - case tag - case platform - case endpointID - case identityGeneration - } - - /// The broker-owned binding UUID. - public let bindingID: String - - /// The account device UUID associated with the installation. - public let deviceID: String - - /// The installation's broker-facing app-instance UUID. - public let appInstanceID: String - - /// The build tag registered with the broker. - public let tag: String - - /// The endpoint's platform role. - public let platform: CmxIrohPlatform - - /// The cryptographic endpoint identity bound by the broker. - public let endpointID: CmxIrohPeerIdentity - - /// The monotonically increasing endpoint identity generation. - public let identityGeneration: Int - - /// Creates validated broker binding metadata. - /// - /// - Parameters: - /// - bindingID: The broker-owned lowercase binding UUID. - /// - deviceID: The account device's lowercase UUID. - /// - appInstanceID: The installation's lowercase app-instance UUID. - /// - tag: The safe build tag sent during registration. - /// - platform: The endpoint's platform role. - /// - endpointID: The registered Iroh endpoint identity. - /// - identityGeneration: The positive endpoint identity generation. - /// - Throws: ``CmxIrohBrokerCredentialRepositoryError/invalidBinding`` for malformed input. - public init( - bindingID: String, - deviceID: String, - appInstanceID: String, - tag: String, - platform: CmxIrohPlatform, - endpointID: CmxIrohPeerIdentity, - identityGeneration: Int - ) throws { - guard Self.isCanonicalUUID(bindingID), - Self.isCanonicalUUID(deviceID), - Self.isCanonicalUUID(appInstanceID), - Self.isSafeTag(tag), - (1 ... Int(Int32.max)).contains(identityGeneration) else { - throw CmxIrohBrokerCredentialRepositoryError.invalidBinding - } - self.bindingID = bindingID - self.deviceID = deviceID - self.appInstanceID = appInstanceID - self.tag = tag - self.platform = platform - self.endpointID = endpointID - self.identityGeneration = identityGeneration - } - - /// Copies the exact recovery tuple from a validated broker response. - /// - /// - Parameter binding: The binding returned by registration or discovery. - public init(binding: CmxIrohBrokerBinding) { - bindingID = binding.bindingID - deviceID = binding.deviceID - appInstanceID = binding.appInstanceID - tag = binding.tag - platform = binding.platform - endpointID = binding.endpointID - identityGeneration = binding.identityGeneration - } - - /// Decodes and revalidates persisted broker binding metadata. - /// - /// - Parameter decoder: The decoder containing one binding tuple. - /// - Throws: ``CmxIrohBrokerCredentialRepositoryError/invalidBinding`` for malformed input. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - bindingID: container.decode(String.self, forKey: .bindingID), - deviceID: container.decode(String.self, forKey: .deviceID), - appInstanceID: container.decode(String.self, forKey: .appInstanceID), - tag: container.decode(String.self, forKey: .tag), - platform: container.decode(CmxIrohPlatform.self, forKey: .platform), - endpointID: container.decode(CmxIrohPeerIdentity.self, forKey: .endpointID), - identityGeneration: container.decode(Int.self, forKey: .identityGeneration) - ) - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func isSafeTag(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift deleted file mode 100644 index ea30aabd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepository.swift +++ /dev/null @@ -1,366 +0,0 @@ -import CryptoKit -public import Foundation - -/// Persists one active account's broker binding and relay capability. -public actor CmxIrohBrokerCredentialRepository { - private static let activeScopeKey = "cmux.iroh.broker-credentials.scope.v1" - private static let bindingKey = "cmux.iroh.broker-credentials.binding.v1" - - private let secureStore: any CmxIrohSecureCredentialStoring - private let installState: any CmxIrohInstallStateStoring - private var lifecycleEpoch: UInt64 = 0 - private var deactivationCount = 0 - private var activeStorageMutationCount = 0 - private var storageMutationDrainWaiters: [CheckedContinuation<Void, Never>] = [] - - /// Creates a broker credential repository with injectable persistence. - /// - /// - Parameters: - /// - secureStore: Device-only Keychain storage for relay capabilities. - /// - installState: Non-secret defaults storage for the active binding tuple. - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore(), - installState: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore() - ) { - self.secureStore = secureStore - self.installState = installState - } - - /// Loads binding metadata for one exact account and app instance. - /// - /// Activating a different scope first removes all state from the prior - /// account or app instance so returning to it cannot resurrect credentials. - /// - /// - Parameters: - /// - accountID: The authenticated account identifier. - /// - appInstanceID: The installation's lowercase app-instance UUID. - /// - Returns: The active binding metadata, or `nil` when registration is required. - /// - Throws: A scope-validation or secure-storage error. - public func loadBinding( - accountID: String, - appInstanceID: String - ) async throws -> CmxIrohBrokerBindingMetadata? { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: appInstanceID, - epoch: epoch - ) - return try await loadBinding( - scope: scope, - appInstanceID: appInstanceID, - epoch: epoch - ) - } - - /// Saves an exact broker binding, invalidating relay credentials if it changed. - /// - /// - Parameters: - /// - binding: The binding tuple returned by registration or discovery. - /// - accountID: The authenticated account identifier. - /// - Throws: A scope-validation, encoding, or secure-storage error. - public func saveBinding( - _ binding: CmxIrohBrokerBindingMetadata, - accountID: String - ) async throws { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) - let existing = try await loadBinding( - scope: scope, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) - if existing != binding { - try await deleteSecureRecord(account: scope, epoch: epoch) - } - let encoded = try JSONEncoder().encode(binding) - try requireCurrent(epoch) - installState.set(String(decoding: encoded, as: UTF8.self), forKey: Self.bindingKey) - } - - /// Loads a fresh relay credential for one exact binding and managed fleet. - /// - /// Stale, corrupt, wrong-binding, and wrong-fleet capabilities are deleted - /// and returned as a cache miss. - /// - /// - Parameters: - /// - accountID: The authenticated account identifier. - /// - binding: The exact active binding tuple. - /// - expectedRelayFleet: The complete configured managed relay fleet. - /// - now: The validation time. - /// - Returns: A validated relay credential, or `nil` when a new mint is required. - /// - Throws: A scope-validation or secure-storage error. - public func loadRelayCredential( - accountID: String, - binding: CmxIrohBrokerBindingMetadata, - expectedRelayFleet: Set<String>, - now: Date - ) async throws -> CmxIrohRelayTokenResponse? { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) - guard try await loadBinding( - scope: scope, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) == binding else { - try await deleteSecureRecord(account: scope, epoch: epoch) - return nil - } - guard let data = try await readSecureRecord(account: scope, epoch: epoch), - let stored = try? JSONDecoder().decode( - CmxIrohStoredRelayCredential.self, - from: data - ), - stored.version == CmxIrohStoredRelayCredential.currentVersion, - stored.binding == binding, - hasExactFleet(stored.response.relayFleet, expected: expectedRelayFleet), - (try? stored.response.relayConfigurations(now: now))?.count - == expectedRelayFleet.count else { - try await deleteSecureRecord(account: scope, epoch: epoch) - return nil - } - try requireCurrent(epoch) - return stored.response - } - - /// Saves a fresh relay credential for one exact binding and managed fleet. - /// - /// - Parameters: - /// - response: The relay token response returned by the trust broker. - /// - accountID: The authenticated account identifier. - /// - binding: The exact active binding tuple. - /// - expectedRelayFleet: The complete configured managed relay fleet. - /// - now: The validation time. - /// - Throws: A validation, encoding, or secure-storage error. - public func saveRelayCredential( - _ response: CmxIrohRelayTokenResponse, - accountID: String, - binding: CmxIrohBrokerBindingMetadata, - expectedRelayFleet: Set<String>, - now: Date - ) async throws { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) - guard let storedBinding = try await loadBinding( - scope: scope, - appInstanceID: binding.appInstanceID, - epoch: epoch - ) else { - throw CmxIrohBrokerCredentialRepositoryError.bindingNotStored - } - guard storedBinding == binding else { - try await deleteSecureRecord(account: scope, epoch: epoch) - throw CmxIrohBrokerCredentialRepositoryError.bindingMismatch - } - guard hasExactFleet(response.relayFleet, expected: expectedRelayFleet) else { - throw CmxIrohBrokerCredentialRepositoryError.relayFleetMismatch - } - guard (try? response.relayConfigurations(now: now))?.count - == expectedRelayFleet.count else { - throw CmxIrohBrokerCredentialRepositoryError.invalidRelayCredential - } - let record = CmxIrohStoredRelayCredential(binding: binding, response: response) - try await writeSecureRecord( - JSONEncoder().encode(record), - account: scope, - accessibility: .afterFirstUnlockThisDeviceOnly, - epoch: epoch - ) - } - - /// Removes a relay credential while preserving its broker binding. - /// - /// - Parameters: - /// - accountID: The authenticated account identifier. - /// - appInstanceID: The installation's lowercase app-instance UUID. - /// - Throws: A scope-validation or secure-storage error. - public func deleteRelayCredential( - accountID: String, - appInstanceID: String - ) async throws { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: appInstanceID, - epoch: epoch - ) - try await deleteSecureRecord(account: scope, epoch: epoch) - } - - /// Removes a broker binding and every capability scoped to it. - /// - /// - Parameters: - /// - accountID: The authenticated account identifier. - /// - appInstanceID: The installation's lowercase app-instance UUID. - /// - Throws: A scope-validation or secure-storage error. - public func deleteBinding( - accountID: String, - appInstanceID: String - ) async throws { - let epoch = try beginOperation() - let scope = try await prepareScope( - accountID: accountID, - appInstanceID: appInstanceID, - epoch: epoch - ) - try await deleteSecureRecord(account: scope, epoch: epoch) - try requireCurrent(epoch) - installState.set(nil, forKey: Self.bindingKey) - } - - /// Removes all broker state during sign-out or local app-instance revocation. - /// - /// - Throws: A secure-storage error. - public func deactivate() async throws { - lifecycleEpoch &+= 1 - deactivationCount += 1 - defer { deactivationCount -= 1 } - await waitForStorageMutations() - try await secureStore.deleteAll() - installState.set(nil, forKey: Self.bindingKey) - installState.set(nil, forKey: Self.activeScopeKey) - } - - private func prepareScope( - accountID: String, - appInstanceID: String, - epoch: UInt64 - ) async throws -> String { - try requireCurrent(epoch) - guard !accountID.isEmpty, - accountID.utf8.count <= 1_024, - Self.isCanonicalUUID(appInstanceID) else { - throw CmxIrohBrokerCredentialRepositoryError.invalidScope - } - let scope = Self.scope(accountID: accountID, appInstanceID: appInstanceID) - guard installState.string(forKey: Self.activeScopeKey) != scope else { - return scope - } - try await deleteAllSecureRecords(epoch: epoch) - try requireCurrent(epoch) - installState.set(nil, forKey: Self.bindingKey) - installState.set(scope, forKey: Self.activeScopeKey) - return scope - } - - private func loadBinding( - scope: String, - appInstanceID: String, - epoch: UInt64 - ) async throws -> CmxIrohBrokerBindingMetadata? { - try requireCurrent(epoch) - guard let encoded = installState.string(forKey: Self.bindingKey) else { - return nil - } - guard let binding = try? JSONDecoder().decode( - CmxIrohBrokerBindingMetadata.self, - from: Data(encoded.utf8) - ), binding.appInstanceID == appInstanceID else { - installState.set(nil, forKey: Self.bindingKey) - try await deleteSecureRecord(account: scope, epoch: epoch) - return nil - } - try requireCurrent(epoch) - return binding - } - - private func beginOperation() throws -> UInt64 { - guard deactivationCount == 0 else { throw CancellationError() } - return lifecycleEpoch - } - - private func requireCurrent(_ epoch: UInt64) throws { - guard deactivationCount == 0, - lifecycleEpoch == epoch else { throw CancellationError() } - } - - private func readSecureRecord( - account: String, - epoch: UInt64 - ) async throws -> Data? { - try requireCurrent(epoch) - let data = try await secureStore.read(account: account) - try requireCurrent(epoch) - return data - } - - private func writeSecureRecord( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility, - epoch: UInt64 - ) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.write( - data, - account: account, - accessibility: accessibility - ) - try requireCurrent(epoch) - } - - private func deleteSecureRecord( - account: String, - epoch: UInt64 - ) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.delete(account: account) - try requireCurrent(epoch) - } - - private func deleteAllSecureRecords(epoch: UInt64) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.deleteAll() - try requireCurrent(epoch) - } - - private func finishStorageMutation() { - activeStorageMutationCount -= 1 - guard activeStorageMutationCount == 0 else { return } - let waiters = storageMutationDrainWaiters - storageMutationDrainWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - } - - private func waitForStorageMutations() async { - guard activeStorageMutationCount > 0 else { return } - await withCheckedContinuation { continuation in - storageMutationDrainWaiters.append(continuation) - } - } - - private func hasExactFleet(_ fleet: [String], expected: Set<String>) -> Bool { - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(expected.count) - && fleet.count == expected.count - && Set(fleet) == expected - } - - private static func scope(accountID: String, appInstanceID: String) -> String { - let transcript = Data( - "cmux/iroh/broker-credential-scope/v1\0\(accountID)\0\(appInstanceID)".utf8 - ) - return SHA256.hash(data: transcript).map { String(format: "%02x", $0) }.joined() - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepositoryError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepositoryError.swift deleted file mode 100644 index 6ce00ea4..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerCredentialRepositoryError.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Validation failures owned by durable Iroh broker state. -public enum CmxIrohBrokerCredentialRepositoryError: Error, Equatable, Sendable { - /// The account or app-instance scope is malformed. - case invalidScope - - /// Broker binding metadata is malformed. - case invalidBinding - - /// Binding metadata does not belong to the requested app-instance scope. - case bindingScopeMismatch - - /// Relay credentials were saved before their exact binding metadata. - case bindingNotStored - - /// The supplied binding differs from the active broker binding. - case bindingMismatch - - /// The credential does not cover exactly the configured managed relay fleet. - case relayFleetMismatch - - /// The relay token or its lifetime is malformed or no longer fresh. - case invalidRelayCredential -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift deleted file mode 100644 index 9bde4e96..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerModels.swift +++ /dev/null @@ -1,337 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// One active endpoint binding returned by the authenticated trust broker. -public struct CmxIrohBrokerBinding: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case bindingID = "binding_id" - case deviceID = "device_id" - case appInstanceID = "app_instance_id" - case tag - case platform - case displayName = "display_name" - case endpointID = "endpoint_id" - case identityGeneration = "identity_generation" - case pairingEnabled = "pairing_enabled" - case capabilities - case pathHints = "path_hints" - case directPorts = "direct_ports" - case lastSeenAt = "last_seen_at" - } - - public let bindingID: String - public let deviceID: String - public let appInstanceID: String - public let tag: String - public let platform: CmxIrohPlatform - public let displayName: String? - public let endpointID: CmxIrohPeerIdentity - public let identityGeneration: Int - public let pairingEnabled: Bool - public let capabilities: [String] - public let pathHints: [CmxIrohPathHint] - public let directPorts: CmxIrohDirectPorts? - public let lastSeenAt: String - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let bindingID = try container.decode(String.self, forKey: .bindingID) - let deviceID = try container.decode(String.self, forKey: .deviceID) - let appInstanceID = try container.decode(String.self, forKey: .appInstanceID) - let tag = try container.decode(String.self, forKey: .tag) - let endpointID = try container.decode(String.self, forKey: .endpointID) - let identityGeneration = try container.decode(Int.self, forKey: .identityGeneration) - let capabilities = try container.decode([String].self, forKey: .capabilities) - let displayName = try container.decodeIfPresent(String.self, forKey: .displayName) - let pathHints = try container.decode([CmxIrohPathHint].self, forKey: .pathHints) - let directPorts = try container.decodeIfPresent( - CmxIrohDirectPorts.self, - forKey: .directPorts - ) - let lastSeenAt = try container.decode(String.self, forKey: .lastSeenAt) - guard Self.isCanonicalUUID(bindingID), - Self.isCanonicalUUID(deviceID), - Self.isCanonicalUUID(appInstanceID), - Self.isSafeToken(tag), - (1 ... Int(Int32.max)).contains(identityGeneration), - capabilities.count <= 32, - Set(capabilities).count == capabilities.count, - capabilities.allSatisfy(Self.isSafeToken), - displayName.map(Self.isSafeDisplayName) ?? true, - pathHints.count <= CmxAttachEndpoint.maximumIrohPathHintCount, - pathHints.filter({ $0.kind == .relayURL }).count <= 2, - pathHints.allSatisfy(Self.isBrokerHint), - !pathHints.enumerated().contains(where: { index, hint in - pathHints[..<index].contains(hint) - }), - CmxIrohISO8601Date.parse(lastSeenAt) != nil else { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid Iroh binding") - ) - } - self.bindingID = bindingID - self.deviceID = deviceID - self.appInstanceID = appInstanceID - self.tag = tag - platform = try container.decode(CmxIrohPlatform.self, forKey: .platform) - self.displayName = displayName - self.endpointID = try CmxIrohPeerIdentity(endpointID: endpointID) - self.identityGeneration = identityGeneration - pairingEnabled = try container.decode(Bool.self, forKey: .pairingEnabled) - self.capabilities = capabilities - self.pathHints = pathHints - self.directPorts = directPorts - self.lastSeenAt = lastSeenAt - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(bindingID, forKey: .bindingID) - try container.encode(deviceID, forKey: .deviceID) - try container.encode(appInstanceID, forKey: .appInstanceID) - try container.encode(tag, forKey: .tag) - try container.encode(platform, forKey: .platform) - try container.encodeIfPresent(displayName, forKey: .displayName) - try container.encode(endpointID.endpointID, forKey: .endpointID) - try container.encode(identityGeneration, forKey: .identityGeneration) - try container.encode(pairingEnabled, forKey: .pairingEnabled) - try container.encode(capabilities, forKey: .capabilities) - try container.encode(pathHints, forKey: .pathHints) - try container.encodeIfPresent(directPorts, forKey: .directPorts) - try container.encode(lastSeenAt, forKey: .lastSeenAt) - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func isSafeToken(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } - - private static func isSafeDisplayName(_ value: String) -> Bool { - !value.isEmpty - && value.utf16.count <= 128 - && !value.unicodeScalars.contains(where: { - $0.value <= 0x1f || $0.value == 0x7f - }) - } - - private static func isBrokerHint(_ hint: CmxIrohPathHint) -> Bool { - guard hint.isSafeForCurrentWireFormat, - hint.kind != .relayIdentifier, - let observedAt = hint.observedAt, - let expiresAt = hint.expiresAt, - expiresAt > observedAt, - expiresAt <= observedAt.addingTimeInterval(CmxIrohPathHint.maximumPrivateHintTTL) - else { - return false - } - return true - } - -} - -/// Broker-published Ed25519 key used to verify grants and attestations locally. -public struct CmxIrohGrantVerificationKey: Codable, Equatable, Sendable { - public let kid: String - public let alg: String - public let spkiDerBase64: String - - private enum CodingKeys: String, CodingKey { - case kid - case alg - case spkiDerBase64 = "spki_der_base64" - } -} - -/// Current and previous broker keys accepted during a staged signing-key rotation. -public struct CmxIrohGrantVerificationKeySet: Codable, Equatable, Sendable { - public let version: Int - public let currentKeyID: String - public let keys: [CmxIrohGrantVerificationKey] - - private enum CodingKeys: String, CodingKey { - case version - case currentKeyID = "current_kid" - case keys - } -} - -/// Same-account LAN rendezvous material. It is never advertised directly in mDNS. -public struct CmxIrohLANRendezvous: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case generation - case key - } - - public let generation: Int - public let key: String - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let generation = try container.decode(Int.self, forKey: .generation) - let key = try container.decode(String.self, forKey: .key) - guard (1 ... Int(Int32.max)).contains(generation), - Self.decodeBase64URL(key)?.count == 32 else { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid LAN rendezvous") - ) - } - self.generation = generation - self.key = key - } - - private static func decodeBase64URL(_ value: String) -> Data? { - guard !value.isEmpty, - value.utf8.allSatisfy({ byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || byte == 45 || byte == 95 - }) else { - return nil - } - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - let standard = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - guard let data = Data(base64Encoded: standard), - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") == value else { - return nil - } - return data - } -} - -/// Authenticated registry snapshot used for endpoint discovery and grant verification. -public struct CmxIrohDiscoveryResponse: Decodable, Equatable, Sendable { - /// Upper bound for one authenticated account snapshot. Production accounts - /// remain server-limited to 32; development accounts may use this larger, - /// still-bounded snapshot for concurrent tagged builds. - public static let maximumBindingCount = 256 - - public let routeContractVersion: Int - public let bindings: [CmxIrohBrokerBinding] - public let relayFleet: [String] - public let lanRendezvous: CmxIrohLANRendezvous - public let grantVerificationKeys: CmxIrohGrantVerificationKeySet - - private enum CodingKeys: String, CodingKey { - case routeContractVersion = "route_contract_version" - case bindings - case relayFleet = "relay_fleet" - case lanRendezvous = "lan_rendezvous" - case grantVerificationKeys = "grant_verification_keys" - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let routeContractVersion = try container.decode(Int.self, forKey: .routeContractVersion) - let bindings = try container.decode([CmxIrohBrokerBinding].self, forKey: .bindings) - let relayFleet = try container.decode([String].self, forKey: .relayFleet) - guard bindings.count <= Self.maximumBindingCount, - Set(bindings.map(\.bindingID)).count == bindings.count, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - relayFleet.count - ), - Set(relayFleet).count == relayFleet.count, - relayFleet.allSatisfy(Self.isCanonicalRelayURL) else { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid Iroh registry") - ) - } - self.routeContractVersion = routeContractVersion - self.bindings = bindings - self.relayFleet = relayFleet - lanRendezvous = try container.decode(CmxIrohLANRendezvous.self, forKey: .lanRendezvous) - grantVerificationKeys = try container.decode( - CmxIrohGrantVerificationKeySet.self, - forKey: .grantVerificationKeys - ) - } - - private static func isCanonicalRelayURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port == nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } -} - -/// Registration response. Relay bootstrap failure never rolls back the binding. -public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable { - public let binding: CmxIrohBrokerBinding - public let relay: CmxIrohRegistrationRelay -} - -/// Result of the registration route's best-effort initial relay mint. -public enum CmxIrohRegistrationRelay: Decodable, Equatable, Sendable { - case issued(CmxIrohRelayTokenResponse) - case unavailable - case notRequested - - private enum CodingKeys: String, CodingKey { case status } - - public init(from decoder: any Decoder) throws { - let status = try decoder.container(keyedBy: CodingKeys.self) - .decode(String.self, forKey: .status) - switch status { - case "issued": - self = try .issued(CmxIrohRelayTokenResponse(from: decoder)) - case "unavailable": - self = .unavailable - case "not_requested": - self = .notRequested - default: - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Unknown relay status") - ) - } - } -} - -/// Backend-signed seven-day permission for one iOS initiator and Mac acceptor. -public struct CmxIrohPairGrantResponse: Codable, Equatable, Sendable { - public let grant: String - public let expiresAt: String - - private enum CodingKeys: String, CodingKey { - case grant - case expiresAt = "expires_at" - } -} - -/// Backend-signed endpoint/account proof cached for offline same-account pairing. -public struct CmxIrohEndpointAttestationResponse: Codable, Equatable, Sendable { - public let attestationVersion: Int - public let attestation: String - public let expiresAt: String - public let grantVerificationKeys: CmxIrohGrantVerificationKeySet - - private enum CodingKeys: String, CodingKey { - case attestationVersion = "attestation_version" - case attestation - case expiresAt = "expires_at" - case grantVerificationKeys = "grant_verification_keys" - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBufferedReceiveStream.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBufferedReceiveStream.swift deleted file mode 100644 index 106c6854..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBufferedReceiveStream.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation - -/// Preserves bytes read beyond a lane header before delegating to Iroh. -actor CmxIrohBufferedReceiveStream: CmxIrohReceiveStream { - private let base: any CmxIrohReceiveStream - private var buffer: Data - - init(base: any CmxIrohReceiveStream, buffer: Data) { - self.base = base - self.buffer = buffer - } - - func receive(maximumByteCount: Int) async throws -> Data? { - guard maximumByteCount > 0 else { - throw CmxIrohClientSessionError.invalidMaximumByteCount(maximumByteCount) - } - if !buffer.isEmpty { - let count = min(maximumByteCount, buffer.count) - let value = Data(buffer.prefix(count)) - buffer.removeFirst(count) - return value - } - return try await base.receive(maximumByteCount: maximumByteCount) - } - - func stop(errorCode: UInt64) async { - buffer.removeAll(keepingCapacity: false) - await base.stop(errorCode: errorCode) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransport.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransport.swift deleted file mode 100644 index 30f4c338..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransport.swift +++ /dev/null @@ -1,119 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Adapts an admitted Iroh control stream to the existing mobile RPC byte seam. -public actor CmxIrohByteTransport: CmxByteTransport { - private let request: CmxByteTransportRequest - private let supervisor: CmxIrohEndpointSupervisor - private let contextProvider: any CmxIrohClientContextProvider - private var connectTask: Task<CmxIrohClientSession, any Error>? - private var session: CmxIrohClientSession? - private var closed = false - - /// Creates a disconnected byte transport. - /// - /// - Parameters: - /// - request: The validated Iroh peer route and intended Mac binding. - /// - supervisor: The active endpoint owner. - /// - contextProvider: The fresh dial-plan and grant provider. - public init( - request: CmxByteTransportRequest, - supervisor: CmxIrohEndpointSupervisor, - contextProvider: any CmxIrohClientContextProvider - ) { - self.request = request - self.supervisor = supervisor - self.contextProvider = contextProvider - } - - /// Resolves current trust and reachability, then admits the control stream. - /// - /// Concurrent callers share one cancellable dial operation. - /// - /// - Throws: A route, registry, endpoint, transport, or cancellation error. - public func connect() async throws { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - if session != nil { return } - guard case let .peer(identity, _) = request.route.endpoint else { - throw CmxIrohByteTransportError.unsupportedEndpoint(request.route.endpoint) - } - - let task: Task<CmxIrohClientSession, any Error> - if let connectTask { - task = connectTask - } else { - let supervisor = supervisor - let contextProvider = contextProvider - let request = request - task = Task { - let endpoint = try await supervisor.activeEndpoint() - let context = try await contextProvider.context(for: request) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: identity, - dialPlan: context.dialPlan, - credential: context.credential, - privateFallbackAuthorization: context.privateFallbackAuthorization, - privateFallbackValidator: contextProvider - ) - do { - try await session.connect() - try Task.checkCancellation() - return session - } catch { - await session.close() - throw error - } - } - connectTask = task - } - - do { - let connected = try await withTaskCancellationHandler(operation: { - try await task.value - }, onCancel: { - task.cancel() - }) - if closed { - await connected.close() - throw CmxIrohByteTransportError.alreadyClosed - } - session = connected - connectTask = nil - } catch { - connectTask = nil - throw error - } - } - - /// Receives the next admitted control-lane bytes. - /// - /// - Returns: Application bytes, or `nil` after a clean peer finish. - /// - Throws: A transport or lifecycle error. - public func receive() async throws -> Data? { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - guard let session else { throw CmxIrohByteTransportError.notConnected } - return try await session.receiveControl() - } - - /// Sends a complete application buffer on the admitted control lane. - /// - /// - Parameter data: The RPC framing bytes to send. - /// - Throws: A transport or lifecycle error. - public func send(_ data: Data) async throws { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - guard let session else { throw CmxIrohByteTransportError.notConnected } - try await session.sendControl(data) - } - - /// Cancels any dial and closes the Iroh connection. - public func close() async { - guard !closed else { return } - closed = true - connectTask?.cancel() - connectTask = nil - let closingSession = session - session = nil - await closingSession?.close() - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransportError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransportError.swift deleted file mode 100644 index 3260e9e3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransportError.swift +++ /dev/null @@ -1,22 +0,0 @@ -public import CMUXMobileCore - -/// Route and lifecycle failures raised by ``CmxIrohByteTransport``. -public enum CmxIrohByteTransportError: Error, Equatable, Sendable { - /// A non-Iroh route was passed to the Iroh factory. - case unsupportedRouteKind(CmxAttachTransportKind) - - /// The route does not carry a canonical Iroh peer identity. - case unsupportedEndpoint(CmxAttachEndpoint) - - /// The caller omitted the expected Mac binding or admission authorization mode. - case missingPeerIntent - - /// The transport was closed before the requested operation. - case alreadyClosed - - /// Send or receive was attempted before successful admission. - case notConnected - - /// Another RPC session already owns framing on this peer's control lane. - case controlLaneAlreadyOwned -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransportFactory.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransportFactory.swift deleted file mode 100644 index 3eec1cbd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohByteTransportFactory.swift +++ /dev/null @@ -1,82 +0,0 @@ -public import CMUXMobileCore - -/// Builds Iroh control-lane byte transports for the existing mobile RPC layer. -public struct CmxIrohByteTransportFactory: CmxRouteAwareByteTransportFactory { - /// The route kind served by this factory. - public let supportedKinds: [CmxAttachTransportKind] = [.iroh] - - private let buildTransport: @Sendable ( - _ request: CmxByteTransportRequest - ) -> any CmxByteTransport - - /// Creates an Iroh transport factory. - /// - /// - Parameters: - /// - supervisor: The app-lifecycle endpoint owner. - /// - contextProvider: The authenticated registry and local-policy seam. - public init( - supervisor: CmxIrohEndpointSupervisor, - contextProvider: any CmxIrohClientContextProvider - ) { - buildTransport = { request in - CmxIrohByteTransport( - request: request, - supervisor: supervisor, - contextProvider: contextProvider - ) - } - } - - /// Creates a factory that waits for account-scoped runtime activation on connect. - /// - /// - Parameter deferredProvider: The process-owned runtime composition seam. - public init(deferredProvider: any CmxIrohDeferredTransportProviding) { - buildTransport = { request in - CmxIrohDeferredByteTransport( - request: request, - provider: deferredProvider - ) - } - } - - init(sessionPool: CmxIrohClientSessionPool) { - buildTransport = { request in - CmxIrohPooledByteTransport(request: request, pool: sessionPool) - } - } - - /// Creates a disconnected control-lane adapter for an Iroh peer route. - /// - /// - Parameter route: A validated route whose endpoint is `.peer`. - /// - Returns: A transport that resolves fresh grants and hints on `connect()`. - /// - Throws: ``CmxIrohByteTransportError`` for a route-shape mismatch. - public func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { - try route.validate() - guard route.kind == .iroh else { - throw CmxIrohByteTransportError.unsupportedRouteKind(route.kind) - } - guard case .peer = route.endpoint else { - throw CmxIrohByteTransportError.unsupportedEndpoint(route.endpoint) - } - throw CmxIrohByteTransportError.missingPeerIntent - } - - /// Creates a disconnected transport bound to the intended Mac device. - public func makeTransport( - for request: CmxByteTransportRequest - ) throws -> any CmxByteTransport { - let route = request.route - try route.validate() - guard route.kind == .iroh else { - throw CmxIrohByteTransportError.unsupportedRouteKind(route.kind) - } - guard case .peer = route.endpoint else { - throw CmxIrohByteTransportError.unsupportedEndpoint(route.endpoint) - } - guard request.authorizationMode == .transportAdmission, - request.expectedPeerDeviceID?.isEmpty == false else { - throw CmxIrohByteTransportError.missingPeerIntent - } - return buildTransport(request) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCachedHostPolicy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCachedHostPolicy.swift deleted file mode 100644 index 58fb476b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCachedHostPolicy.swift +++ /dev/null @@ -1,125 +0,0 @@ -public import Foundation - -/// A broker-issued Mac host policy eligible for verified offline fallback. -public struct CmxIrohCachedHostPolicy: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case binding - case pairingEnabled - case capabilities - case grantVerificationKeys - case endpointAttestation - case lanRendezvous - } - - /// The exact registered broker binding recovered by this policy. - public let binding: CmxIrohBrokerBindingMetadata - - /// The broker-approved offline pairing state. - public let pairingEnabled: Bool - - /// The complete broker-approved host capability set. - public let capabilities: [String] - - /// The broker signing keyset used to verify grants and this attestation. - public let grantVerificationKeys: CmxIrohGrantVerificationKeySet - - /// The signed, short-lived proof for this exact endpoint binding. - public let endpointAttestation: CmxIrohEndpointAttestationResponse - - /// Same-account material used to derive private rotating LAN aliases. - public let lanRendezvous: CmxIrohLANRendezvous - - /// Creates a cache candidate from exact broker policy values. - /// - /// Cryptographic signature, binding, and time validation happens again in - /// ``CmxIrohHostPolicyCache/save(_:for:now:)`` before persistence. - /// - /// - Parameters: - /// - binding: The exact broker binding metadata. - /// - pairingEnabled: The broker-approved offline pairing state. - /// - capabilities: The complete broker-approved capability set. - /// - grantVerificationKeys: The authenticated broker verification keyset. - /// - endpointAttestation: The broker-signed endpoint attestation response. - /// - Throws: ``CmxIrohHostPolicyCacheError/invalidPolicy`` for malformed policy shape. - public init( - binding: CmxIrohBrokerBindingMetadata, - pairingEnabled: Bool, - capabilities: [String], - grantVerificationKeys: CmxIrohGrantVerificationKeySet, - endpointAttestation: CmxIrohEndpointAttestationResponse, - lanRendezvous: CmxIrohLANRendezvous - ) throws { - guard binding.platform == .mac, - capabilities.count <= 32, - Set(capabilities).count == capabilities.count, - capabilities.allSatisfy(Self.isSafeToken), - endpointAttestation.attestationVersion == 1, - endpointAttestation.grantVerificationKeys == grantVerificationKeys else { - throw CmxIrohHostPolicyCacheError.invalidPolicy - } - self.binding = binding - self.pairingEnabled = pairingEnabled - self.capabilities = capabilities - self.grantVerificationKeys = grantVerificationKeys - self.endpointAttestation = endpointAttestation - self.lanRendezvous = lanRendezvous - } - - /// Creates a cache candidate directly from one validated broker binding. - /// - /// - Parameters: - /// - binding: The binding returned by registration or authenticated discovery. - /// - grantVerificationKeys: The authenticated discovery keyset. - /// - endpointAttestation: The broker-signed endpoint attestation response. - /// - Throws: ``CmxIrohHostPolicyCacheError/invalidPolicy`` for malformed policy shape. - public init( - binding: CmxIrohBrokerBinding, - grantVerificationKeys: CmxIrohGrantVerificationKeySet, - endpointAttestation: CmxIrohEndpointAttestationResponse, - lanRendezvous: CmxIrohLANRendezvous - ) throws { - try self.init( - binding: CmxIrohBrokerBindingMetadata(binding: binding), - pairingEnabled: binding.pairingEnabled, - capabilities: binding.capabilities, - grantVerificationKeys: grantVerificationKeys, - endpointAttestation: endpointAttestation, - lanRendezvous: lanRendezvous - ) - } - - /// Decodes and revalidates a persisted policy's structural invariants. - /// - /// - Parameter decoder: The decoder containing one cached host policy. - /// - Throws: ``CmxIrohHostPolicyCacheError/invalidPolicy`` or a decoding error. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - binding: container.decode(CmxIrohBrokerBindingMetadata.self, forKey: .binding), - pairingEnabled: container.decode(Bool.self, forKey: .pairingEnabled), - capabilities: container.decode([String].self, forKey: .capabilities), - grantVerificationKeys: container.decode( - CmxIrohGrantVerificationKeySet.self, - forKey: .grantVerificationKeys - ), - endpointAttestation: container.decode( - CmxIrohEndpointAttestationResponse.self, - forKey: .endpointAttestation - ), - lanRendezvous: container.decode( - CmxIrohLANRendezvous.self, - forKey: .lanRendezvous - ) - ) - } - - private static func isSafeToken(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohChallengeRequest.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohChallengeRequest.swift deleted file mode 100644 index c61fe93e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohChallengeRequest.swift +++ /dev/null @@ -1,24 +0,0 @@ -/// First registration leg that binds a broker nonce to exact endpoint state. -public struct CmxIrohChallengeRequest: Encodable, Equatable, Sendable { - /// Stable app-generated device UUID. - public let deviceId: String - /// Stable app-instance UUID. - public let appInstanceId: String - /// Safe build or app-instance tag. - public let tag: String - /// Exact Iroh EndpointID that will sign the challenge. - public let endpointId: String - /// Endpoint identity generation. - public let identityGeneration: Int - /// SHA-256 of the exact base64url-decoded payload bytes. - public let payloadSha256: String - - init(payload: CmxIrohRegistrationPayload, payloadSHA256: String) { - deviceId = payload.deviceID - appInstanceId = payload.appInstanceID - tag = payload.tag - endpointId = payload.endpointID - identityGeneration = payload.identityGeneration - payloadSha256 = payloadSHA256 - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohChallengeResponse.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohChallengeResponse.swift deleted file mode 100644 index 6f7a072d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohChallengeResponse.swift +++ /dev/null @@ -1,22 +0,0 @@ -/// Broker-issued nonce used for one endpoint registration attempt. -public struct CmxIrohChallengeResponse: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case challengeID = "challenge_id" - case nonce - case expiresAt = "expires_at" - } - - /// One-use broker challenge UUID. - public let challengeID: String - /// Canonical base64url encoding of 32 random bytes. - public let nonce: String - /// Broker expiry supplied for scheduling and diagnostics. - public let expiresAt: String - - /// Creates a challenge response value. - public init(challengeID: String, nonce: String, expiresAt: String) { - self.challengeID = challengeID - self.nonce = nonce - self.expiresAt = expiresAt - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift deleted file mode 100644 index 694c1610..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientBrokerServing.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// Trust-broker operations required by an iOS Iroh client runtime. -public protocol CmxIrohClientBrokerServing: CmxIrohRegistryServing, - CmxIrohRelayTokenServing, CmxIrohBindingRevoking -{ - /// Registers an endpoint using its challenge-bound identity proof. - func register( - prepared: CmxIrohPreparedRegistration, - signer: CmxIrohRegistrationSigner - ) async throws -> CmxIrohRegistrationResponse -} - -extension CmxIrohTrustBrokerClient: CmxIrohClientBrokerServing {} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift deleted file mode 100644 index 80901c78..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContext.swift +++ /dev/null @@ -1,30 +0,0 @@ -public import CMUXMobileCore - -/// The current signed authorization and route tiers for one Iroh dial. -public struct CmxIrohClientContext: Equatable, Sendable { - /// Public paths followed by profile-gated private fallback paths. - public let dialPlan: CmxIrohDialPlan - - /// The admission proof bound to the exact local and remote endpoints. - public let credential: CmxIrohAdmissionCredential - - /// The generation-bound authorization for explicit private fallback hints. - public let privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? - - /// Creates a client dial context. - /// - /// - Parameters: - /// - dialPlan: The explicit two-phase reachability plan. - /// - credential: The signed grant or offline pairing proof. - /// - privateFallbackAuthorization: The local generation snapshot that - /// admitted the plan's private hints, or `nil` for a public-only plan. - public init( - dialPlan: CmxIrohDialPlan, - credential: CmxIrohAdmissionCredential, - privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? = nil - ) { - self.dialPlan = dialPlan - self.credential = credential - self.privateFallbackAuthorization = privateFallbackAuthorization - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift deleted file mode 100644 index c5d7975f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientContextProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -public import CMUXMobileCore - -/// Resolves current reachability policy and admission proof for an Iroh route. -public protocol CmxIrohClientContextProvider: CmxIrohPrivateFallbackValidating, Sendable { - /// Resolves one same-account dial context at connection time. - /// - /// - Parameter request: The validated route and expected Mac device binding. - /// - Returns: Current route tiers and an endpoint-bound credential. - /// - Throws: A registry, account, expiry, or local policy error. - func context(for request: CmxByteTransportRequest) async throws -> CmxIrohClientContext - - /// Refreshes generation-scoped private reachability after public dialing fails. - func contextWithPrivateFallback( - for request: CmxByteTransportRequest, - basedOn context: CmxIrohClientContext - ) async throws -> CmxIrohClientContext -} - -public extension CmxIrohClientContextProvider { - /// Providers without a dynamic private source preserve the initial context. - func contextWithPrivateFallback( - for _: CmxByteTransportRequest, - basedOn context: CmxIrohClientContext - ) async throws -> CmxIrohClientContext { - context - } - - /// Generation-less providers cannot authorize a private fallback. - func validatePrivateFallback( - _: CmxIrohPrivateFallbackAuthorization - ) async throws { - throw CmxIrohPrivateFallbackValidationError.unavailable - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift deleted file mode 100644 index d79f6b2b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyCache.swift +++ /dev/null @@ -1,460 +0,0 @@ -import CryptoKit -public import CMUXMobileCore -public import Foundation - -/// Stores a bounded set of signed pair authorities for connectivity-only fallback. -public actor CmxIrohClientOfflinePolicyCache { - public static let maximumTargetCount = CmxIrohDiscoveryResponse.maximumBindingCount - private static let storageAccount = "active-client-policies" - - private let secureStore: any CmxIrohSecureCredentialStoring - private let verifier: CmxIrohGrantVerifier - private var lifecycleEpoch: UInt64 = 0 - private var deactivationCount = 0 - private var activeStorageMutationCount = 0 - private var storageMutationDrainWaiters: [CheckedContinuation<Void, Never>] = [] - - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( - service: "com.cmuxterm.iroh.client-offline-policy.v1" - ), - verifier: CmxIrohGrantVerifier = CmxIrohGrantVerifier() - ) { - self.secureStore = secureStore - self.verifier = verifier - } - - /// Merges one online-verified target into the bounded active-account cache. - public func save( - localBinding: CmxIrohBrokerBinding, - targetBinding: CmxIrohBrokerBinding, - discovery: CmxIrohDiscoveryResponse, - pairGrant: CmxIrohPairGrantResponse, - for expectation: CmxIrohClientOfflinePolicyExpectation, - now: Date - ) async throws { - let epoch = try beginOperation() - try validateDiscovery(discovery, for: expectation) - guard expectation.localBindingExpectation.matches(localBinding), - discovery.bindings.filter({ - expectation.localBindingExpectation.matches($0) - }).count == 1, - discovery.bindings.filter({ $0 == localBinding }).count == 1, - discovery.bindings.filter({ $0 == targetBinding }).count == 1, - discovery.bindings.filter({ - $0.platform == .mac && $0.endpointID == targetBinding.endpointID - }).count == 1, - targetBinding.platform == .mac, - targetBinding.pairingEnabled else { - throw CmxIrohClientOfflinePolicyCacheError.invalidPolicy - } - try validateGrant( - pairGrant, - localBinding: localBinding, - targetBinding: targetBinding, - keys: discovery.grantVerificationKeys, - now: now - ) - - var retained: [CmxIrohStoredClientPolicyTarget] = [] - let storedData = try await secureStore.read(account: Self.storageAccount) - try requireCurrent(epoch) - if let data = storedData, - let record = try? JSONDecoder().decode(CmxIrohStoredClientPolicyRecord.self, from: data), - record.version == CmxIrohStoredClientPolicyRecord.currentVersion, - record.scopeDigest == Self.scopeDigest(for: expectation), - Self.sameAuthority(record.localBinding, localBinding) { - for stored in record.targets { - guard let fresh = Self.uniqueBinding( - in: discovery.bindings, - matchingAuthorityOf: stored.binding - ), - fresh.platform == .mac, - fresh.pairingEnabled, - (try? validateGrant( - stored.pairGrant, - localBinding: localBinding, - targetBinding: fresh, - keys: discovery.grantVerificationKeys, - now: now - )) != nil else { - continue - } - retained.append(.init(binding: fresh, pairGrant: stored.pairGrant)) - } - } - - let candidate = CmxIrohStoredClientPolicyTarget( - binding: targetBinding, - pairGrant: pairGrant - ) - var merged = [candidate] - merged.append(contentsOf: retained.filter { - $0.binding.deviceID != targetBinding.deviceID - && $0.binding.endpointID != targetBinding.endpointID - && $0.binding.bindingID != targetBinding.bindingID - }) - if merged.count > Self.maximumTargetCount { - merged.removeLast(merged.count - Self.maximumTargetCount) - } - let record = CmxIrohStoredClientPolicyRecord( - version: CmxIrohStoredClientPolicyRecord.currentVersion, - scopeDigest: Self.scopeDigest(for: expectation), - localBinding: localBinding, - relayFleet: discovery.relayFleet.sorted(), - grantVerificationKeys: discovery.grantVerificationKeys, - lanRendezvous: discovery.lanRendezvous, - targets: merged - ) - try await writeStoredRecord( - JSONEncoder().encode(record), - epoch: epoch - ) - try requireCurrent(epoch) - } - - /// Loads authority for exactly the requested, already-known Mac tuple. - public func load( - for request: CmxByteTransportRequest, - localBinding: CmxIrohBrokerBinding, - expectation: CmxIrohClientOfflinePolicyExpectation, - confirmedDiscovery: CmxIrohDiscoveryResponse?, - now: Date - ) async throws -> CmxIrohCachedClientPolicy? { - let epoch = try beginOperation() - guard request.route.kind == .iroh, - request.authorizationMode == .transportAdmission, - let expectedDeviceID = request.expectedPeerDeviceID, - case let .peer(expectedEndpointID, _) = request.route.endpoint else { - try requireCurrent(epoch) - return nil - } - guard var record = try await loadRecord( - for: expectation, - confirmedLocalBinding: localBinding, - epoch: epoch - ) else { - try requireCurrent(epoch) - return nil - } - try requireCurrent(epoch) - - let authority: ( - local: CmxIrohBrokerBinding, - targets: [CmxIrohBrokerBinding], - keys: CmxIrohGrantVerificationKeySet, - lan: CmxIrohLANRendezvous - ) - if let confirmedDiscovery { - try validateDiscovery(confirmedDiscovery, for: expectation) - let localMatches = confirmedDiscovery.bindings.filter { - expectation.localBindingExpectation.matches($0) - && Self.sameAuthority($0, localBinding) - } - guard localMatches.count == 1, let confirmedLocal = localMatches.first else { - try await deleteStoredRecord(epoch: epoch) - try requireCurrent(epoch) - return nil - } - authority = ( - confirmedLocal, - confirmedDiscovery.bindings, - confirmedDiscovery.grantVerificationKeys, - confirmedDiscovery.lanRendezvous - ) - } else { - authority = ( - record.localBinding, - record.targets.map(\.binding), - record.grantVerificationKeys, - record.lanRendezvous - ) - } - - let originalCount = record.targets.count - record = try reverifiedRecord( - record, - localBinding: authority.local, - currentTargets: authority.targets, - keys: authority.keys, - lanRendezvous: authority.lan, - now: now - ) - if record.targets.count != originalCount || confirmedDiscovery != nil { - try await persistOrDelete(record, epoch: epoch) - try requireCurrent(epoch) - } - guard let stored = record.targets.first(where: { - CmxIrohDeviceID($0.binding.deviceID) - == CmxIrohDeviceID(expectedDeviceID) - && $0.binding.endpointID == expectedEndpointID - }) else { - try requireCurrent(epoch) - return nil - } - try requireCurrent(epoch) - return CmxIrohCachedClientPolicy( - localBinding: record.localBinding, - targetBinding: stored.binding, - pairGrant: stored.pairGrant, - grantVerificationKeys: record.grantVerificationKeys, - lanRendezvous: record.lanRendezvous - ) - } - - /// Loads all still-signed known targets for connectivity-only runtime startup. - public func loadBootstrap( - for expectation: CmxIrohClientOfflinePolicyExpectation, - confirmedLocalBinding: CmxIrohBrokerBinding?, - now: Date - ) async throws -> CmxIrohClientOfflineBootstrap? { - let epoch = try beginOperation() - guard var record = try await loadRecord( - for: expectation, - confirmedLocalBinding: confirmedLocalBinding, - epoch: epoch - ) else { - try requireCurrent(epoch) - return nil - } - try requireCurrent(epoch) - let local = confirmedLocalBinding ?? record.localBinding - record = try reverifiedRecord( - record, - localBinding: local, - currentTargets: record.targets.map(\.binding), - keys: record.grantVerificationKeys, - lanRendezvous: record.lanRendezvous, - now: now - ) - try await persistOrDelete(record, epoch: epoch) - try requireCurrent(epoch) - guard !record.targets.isEmpty else { - try requireCurrent(epoch) - return nil - } - try requireCurrent(epoch) - return CmxIrohClientOfflineBootstrap( - localBinding: record.localBinding, - targetBindings: record.targets.map(\.binding), - lanRendezvous: record.lanRendezvous - ) - } - - /// Removes every active-account client policy during account/app teardown. - public func deactivate() async throws { - lifecycleEpoch &+= 1 - deactivationCount += 1 - defer { deactivationCount -= 1 } - await waitForStorageMutationsToDrain() - try await secureStore.deleteAll() - } - - private func loadRecord( - for expectation: CmxIrohClientOfflinePolicyExpectation, - confirmedLocalBinding: CmxIrohBrokerBinding?, - epoch: UInt64 - ) async throws -> CmxIrohStoredClientPolicyRecord? { - let storedData = try await secureStore.read(account: Self.storageAccount) - try requireCurrent(epoch) - guard let data = storedData else { - try requireCurrent(epoch) - return nil - } - do { - let record = try JSONDecoder().decode(CmxIrohStoredClientPolicyRecord.self, from: data) - guard record.version == CmxIrohStoredClientPolicyRecord.currentVersion, - record.scopeDigest == Self.scopeDigest(for: expectation), - record.targets.count <= Self.maximumTargetCount, - Set(record.relayFleet) == expectation.managedRelayURLs, - record.relayFleet.count == expectation.managedRelayURLs.count, - expectation.localBindingExpectation.matches(record.localBinding), - confirmedLocalBinding.map({ - expectation.localBindingExpectation.matches($0) - && Self.sameAuthority($0, record.localBinding) - }) ?? true else { - throw CmxIrohClientOfflinePolicyCacheError.policyMismatch - } - try requireCurrent(epoch) - return record - } catch { - try await deleteStoredRecord(epoch: epoch) - try requireCurrent(epoch) - return nil - } - } - - private func reverifiedRecord( - _ record: CmxIrohStoredClientPolicyRecord, - localBinding: CmxIrohBrokerBinding, - currentTargets: [CmxIrohBrokerBinding], - keys: CmxIrohGrantVerificationKeySet, - lanRendezvous: CmxIrohLANRendezvous, - now: Date - ) throws -> CmxIrohStoredClientPolicyRecord { - var targets: [CmxIrohStoredClientPolicyTarget] = [] - for stored in record.targets { - guard let current = Self.uniqueBinding( - in: currentTargets, - matchingAuthorityOf: stored.binding - ), - current.platform == .mac, - current.pairingEnabled, - (try? validateGrant( - stored.pairGrant, - localBinding: localBinding, - targetBinding: current, - keys: keys, - now: now - )) != nil else { - continue - } - targets.append(.init(binding: current, pairGrant: stored.pairGrant)) - } - return CmxIrohStoredClientPolicyRecord( - version: record.version, - scopeDigest: record.scopeDigest, - localBinding: localBinding, - relayFleet: record.relayFleet, - grantVerificationKeys: keys, - lanRendezvous: lanRendezvous, - targets: Array(targets.prefix(Self.maximumTargetCount)) - ) - } - - private func persistOrDelete( - _ record: CmxIrohStoredClientPolicyRecord, - epoch: UInt64 - ) async throws { - try requireCurrent(epoch) - guard !record.targets.isEmpty else { - try await deleteStoredRecord(epoch: epoch) - try requireCurrent(epoch) - return - } - try await writeStoredRecord( - JSONEncoder().encode(record), - epoch: epoch - ) - try requireCurrent(epoch) - } - - private func beginOperation() throws -> UInt64 { - try Task.checkCancellation() - guard deactivationCount == 0 else { throw CancellationError() } - return lifecycleEpoch - } - - private func requireCurrent(_ epoch: UInt64) throws { - guard deactivationCount == 0, lifecycleEpoch == epoch else { - throw CancellationError() - } - try Task.checkCancellation() - } - - private func writeStoredRecord(_ data: Data, epoch: UInt64) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.write( - data, - account: Self.storageAccount, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - try requireCurrent(epoch) - } - - private func deleteStoredRecord(epoch: UInt64) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.delete(account: Self.storageAccount) - try requireCurrent(epoch) - } - - private func finishStorageMutation() { - activeStorageMutationCount -= 1 - guard activeStorageMutationCount == 0 else { return } - let waiters = storageMutationDrainWaiters - storageMutationDrainWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume() - } - } - - private func waitForStorageMutationsToDrain() async { - guard activeStorageMutationCount > 0 else { return } - await withCheckedContinuation { continuation in - storageMutationDrainWaiters.append(continuation) - } - } - - private func validateDiscovery( - _ discovery: CmxIrohDiscoveryResponse, - for expectation: CmxIrohClientOfflinePolicyExpectation - ) throws { - guard discovery.routeContractVersion == 1, - discovery.relayFleet.count == expectation.managedRelayURLs.count, - Set(discovery.relayFleet) == expectation.managedRelayURLs else { - throw CmxIrohClientOfflinePolicyCacheError.invalidPolicy - } - } - - private func validateGrant( - _ response: CmxIrohPairGrantResponse, - localBinding: CmxIrohBrokerBinding, - targetBinding: CmxIrohBrokerBinding, - keys: CmxIrohGrantVerificationKeySet, - now: Date - ) throws { - let claims = try verifier.verifyPairGrant( - response.grant, - keys: keys, - initiator: CmxIrohGrantPeer(binding: localBinding), - acceptor: CmxIrohGrantPeer(binding: targetBinding), - now: now - ) - let signedExpiry = Date(timeIntervalSince1970: TimeInterval(claims.expiresAt)) - guard let envelopeExpiry = CmxIrohISO8601Date.parse(response.expiresAt), - abs(envelopeExpiry.timeIntervalSince(signedExpiry)) < 1, - envelopeExpiry > now else { - throw CmxIrohClientOfflinePolicyCacheError.invalidGrantEnvelope - } - } - - private static func uniqueBinding( - in bindings: [CmxIrohBrokerBinding], - matchingAuthorityOf expected: CmxIrohBrokerBinding - ) -> CmxIrohBrokerBinding? { - let matches = bindings.filter { sameAuthority($0, expected) } - return matches.count == 1 ? matches[0] : nil - } - - private static func sameAuthority( - _ left: CmxIrohBrokerBinding, - _ right: CmxIrohBrokerBinding - ) -> Bool { - left.bindingID == right.bindingID - && left.deviceID == right.deviceID - && left.appInstanceID == right.appInstanceID - && left.tag == right.tag - && left.platform == right.platform - && left.endpointID == right.endpointID - && left.identityGeneration == right.identityGeneration - && left.pairingEnabled == right.pairingEnabled - && left.capabilities.count == right.capabilities.count - && Set(left.capabilities) == Set(right.capabilities) - } - - private static func scopeDigest( - for expectation: CmxIrohClientOfflinePolicyExpectation - ) -> String { - let transcript = Data( - "cmux/iroh/offline-client-policy-scope/v1\0\(expectation.accountID)\0\(expectation.localBindingExpectation.appInstanceID)".utf8 - ) - return SHA256.hash(data: transcript) - .map { String(format: "%02x", $0) } - .joined() - } - -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift deleted file mode 100644 index f7c8e50f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientOfflinePolicyModels.swift +++ /dev/null @@ -1,106 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Structural failures at the device-only iOS offline-policy boundary. -public enum CmxIrohClientOfflinePolicyCacheError: Error, Equatable, Sendable { - case invalidExpectation - case invalidPolicy - case policyMismatch - case invalidGrantEnvelope -} - -/// The current account, app, endpoint, and relay authority for offline lookup. -public struct CmxIrohClientOfflinePolicyExpectation: Equatable, Sendable { - public let accountID: String - public let localBindingExpectation: CmxIrohLocalBindingExpectation - public let managedRelayURLs: Set<String> - - public init( - accountID: String, - localBindingExpectation: CmxIrohLocalBindingExpectation, - managedRelayURLs: Set<String> - ) throws { - guard !accountID.isEmpty, - accountID.utf8.count <= 1_024, - localBindingExpectation.platform == .ios, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - managedRelayURLs.count - ), - managedRelayURLs.allSatisfy(Self.isCanonicalRelayURL) else { - throw CmxIrohClientOfflinePolicyCacheError.invalidExpectation - } - self.accountID = accountID - self.localBindingExpectation = localBindingExpectation - self.managedRelayURLs = managedRelayURLs - } - - private static func isCanonicalRelayURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port == nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } -} - -/// One exact, reverified iOS-to-Mac authority recovered from device-only storage. -public struct CmxIrohCachedClientPolicy: Equatable, Sendable { - public let localBinding: CmxIrohBrokerBinding - public let targetBinding: CmxIrohBrokerBinding - public let pairGrant: CmxIrohPairGrantResponse - public let grantVerificationKeys: CmxIrohGrantVerificationKeySet - public let lanRendezvous: CmxIrohLANRendezvous -} - -/// Reverified route material used only to bootstrap an already-known account. -public struct CmxIrohClientOfflineBootstrap: Equatable, Sendable { - public let localBinding: CmxIrohBrokerBinding - public let targetBindings: [CmxIrohBrokerBinding] - public let lanRendezvous: CmxIrohLANRendezvous -} - -/// Immutable cache scope installed into a dial-time registry context provider. -public struct CmxIrohClientOfflinePolicyContext: Sendable { - public let cache: CmxIrohClientOfflinePolicyCache - public let expectation: CmxIrohClientOfflinePolicyExpectation - public let localBinding: CmxIrohBrokerBinding - - public init( - cache: CmxIrohClientOfflinePolicyCache, - expectation: CmxIrohClientOfflinePolicyExpectation, - localBinding: CmxIrohBrokerBinding - ) throws { - guard expectation.localBindingExpectation.matches(localBinding) else { - throw CmxIrohClientOfflinePolicyCacheError.policyMismatch - } - self.cache = cache - self.expectation = expectation - self.localBinding = localBinding - } -} - -struct CmxIrohStoredClientPolicyTarget: Codable, Equatable, Sendable { - let binding: CmxIrohBrokerBinding - let pairGrant: CmxIrohPairGrantResponse -} - -struct CmxIrohStoredClientPolicyRecord: Codable, Equatable, Sendable { - static let currentVersion = 1 - - let version: Int - let scopeDigest: String - let localBinding: CmxIrohBrokerBinding - let relayFleet: [String] - let grantVerificationKeys: CmxIrohGrantVerificationKeySet - let lanRendezvous: CmxIrohLANRendezvous - let targets: [CmxIrohStoredClientPolicyTarget] -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift deleted file mode 100644 index 94013f53..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Lifecycle.swift +++ /dev/null @@ -1,108 +0,0 @@ -public import Foundation - -extension CmxIrohClientRuntime { - func performSignOut( - pendingRevocation: CmxIrohPendingRevocation?, - revision: UInt64 - ) async -> CmxIrohClientSignOutPreparation { - async let wasPersisted = Self.persist(pendingRevocation, to: pendingRevocations) - async let networkTeardown: Void = tearDownNetwork(preserveBinding: true) - let (persisted, _) = await (wasPersisted, networkTeardown) - let preparation = CmxIrohClientSignOutPreparation( - pendingRevocation: pendingRevocation, - wasPersisted: persisted - ) - - guard lifecyclePhase == .signingOut, - lifecycleRevision == revision else { - signOutOperation = nil - return preparation - } - guard persisted else { - lifecyclePhase = .quarantined - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .quarantined, - endpointID: nil, - bindingID: pendingRevocation?.bindingID - ) - signOutOperation = nil - return preparation - } - - try? await offlinePolicyCache?.deactivate() - await handleLocalDeactivation() - guard lifecyclePhase == .signingOut, - lifecycleRevision == revision else { - signOutOperation = nil - return preparation - } - localBinding = nil - lifecyclePhase = .inactive - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .inactive, - endpointID: nil, - bindingID: nil - ) - signOutOperation = nil - return preparation - } - - nonisolated static func persist( - _ revocation: CmxIrohPendingRevocation?, - to pendingRevocations: CmxIrohPendingRevocationOutbox - ) async -> Bool { - guard let revocation else { return true } - do { - try await pendingRevocations.enqueue(revocation) - return true - } catch { - return false - } - } - - func tearDownNetwork(preserveBinding: Bool = false) async { - registrationRefreshTask?.cancel() - registrationRefreshTask = nil - registrationRefreshTaskID = nil - registrationRefreshPending = false - registrationRefreshEnabled = false - supervisorEventTask?.cancel() - supervisorEventTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil - await sessionPool.deactivate() - await contextRouter.clear() - if !preserveBinding { localBinding = nil } - await supervisor.deactivate() - } - - func validateRelayFleet(_ fleet: [String]) throws { - guard fleet.count == managedRelayURLs.count, - Set(fleet) == managedRelayURLs else { - throw CmxIrohClientRuntimeError.relayFleetMismatch - } - } - - func requireCurrent(_ revision: UInt64) throws { - guard lifecyclePhase.ownsNetworkOperation, - lifecycleRevision == revision else { - throw CmxIrohClientRuntimeError.superseded - } - } - - static func cachedRelayConfigurations( - configuration: CmxIrohClientRuntimeConfiguration, - now: Date - ) -> [CmxIrohRelayConfiguration] { - guard let cached = configuration.cachedRelayCredential, - cached.relayFleet.count == configuration.managedRelayURLs.count, - Set(cached.relayFleet) == configuration.managedRelayURLs else { - return [] - } - return (try? cached.relayConfigurations(now: now)) ?? [] - } - - static func isConnectivity(_ error: any Error) -> Bool { - (error as? CmxIrohTrustBrokerClientError) == .connectivity - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift deleted file mode 100644 index 43c8c06c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+Policy.swift +++ /dev/null @@ -1,217 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -extension CmxIrohClientRuntime { - func resolvePolicy( - expectedEndpointID: CmxIrohPeerIdentity, - revision: UInt64 - ) async throws -> ResolvedPolicy { - try await pendingRevocations.revokePending( - accountID: configuration.accountID, - beforeRegisteringTag: configuration.tag, - using: broker - ) - try requireCurrent(revision) - let endpoint = try await supervisor.activeEndpoint() - let address = await endpoint.address() - guard address.identity == expectedEndpointID else { - throw CmxIrohClientRuntimeError.invalidLocalBinding - } - let publicHints = Array(address.pathHints.compactMap { - $0.publicDisclosure(at: now()) - }.prefix(CmxAttachEndpoint.maximumIrohPathHintCount)) - let directPorts = CmxIrohDirectPorts( - localDirectAddresses: await endpoint.localDirectAddresses() - ) - let payload = try CmxIrohRegistrationPayload( - deviceID: configuration.deviceID, - appInstanceID: configuration.appInstanceID, - tag: configuration.tag, - platform: .ios, - displayName: configuration.displayName, - endpointID: expectedEndpointID.endpointID, - identityGeneration: configuration.identity.generation, - pairingEnabled: false, - capabilities: configuration.capabilities, - pathHints: publicHints, - directPorts: directPorts, - now: now() - ) - let expectation = try CmxIrohLocalBindingExpectation( - deviceID: configuration.deviceID, - appInstanceID: configuration.appInstanceID, - tag: configuration.tag, - platform: .ios, - endpointID: expectedEndpointID, - identityGeneration: configuration.identity.generation, - pairingEnabled: false, - capabilities: configuration.capabilities - ) - let offlineExpectation = try offlinePolicyCache.map { _ in - try CmxIrohClientOfflinePolicyExpectation( - accountID: configuration.accountID, - localBindingExpectation: expectation, - managedRelayURLs: managedRelayURLs - ) - } - let signer = try CmxIrohRegistrationSigner( - identity: configuration.identity, - endpointID: expectedEndpointID.endpointID - ) - let prepared = try signer.prepare(payload: payload) - let registration: CmxIrohRegistrationResponse - do { - registration = try await broker.register(prepared: prepared, signer: signer) - } catch { - guard Self.isConnectivity(error), - let cached = try await offlineBootstrap( - expectation: offlineExpectation, - confirmedLocalBinding: nil - ) else { throw error } - return ResolvedPolicy( - registration: nil, - discovery: nil, - binding: cached.localBinding, - expectation: expectation, - offlineExpectation: offlineExpectation, - cachedTargetBindings: cached.targetBindings, - cachedLANRendezvous: cached.lanRendezvous - ) - } - try requireCurrent(revision) - guard expectation.matches(registration.binding) else { - throw CmxIrohClientRuntimeError.invalidLocalBinding - } - let discovery: CmxIrohDiscoveryResponse - do { - discovery = try await broker.discover() - } catch { - guard Self.isConnectivity(error), - let cached = try await offlineBootstrap( - expectation: offlineExpectation, - confirmedLocalBinding: registration.binding - ) else { throw error } - return ResolvedPolicy( - registration: registration, - discovery: nil, - binding: cached.localBinding, - expectation: expectation, - offlineExpectation: offlineExpectation, - cachedTargetBindings: cached.targetBindings, - cachedLANRendezvous: cached.lanRendezvous - ) - } - try requireCurrent(revision) - guard discovery.routeContractVersion == payload.routeContractVersion else { - throw CmxIrohClientRuntimeError.routeContractMismatch - } - try validateRelayFleet(discovery.relayFleet) - let localMatches = discovery.bindings.filter(expectation.matches) - guard localMatches.count == 1, - let discovered = localMatches.first, - discovered.bindingID == registration.binding.bindingID else { - throw CmxIrohClientRuntimeError.localBindingMissingFromDiscovery - } - return ResolvedPolicy( - registration: registration, - discovery: discovery, - binding: discovered, - expectation: expectation, - offlineExpectation: offlineExpectation, - cachedTargetBindings: [], - cachedLANRendezvous: nil - ) - } - - func offlineBootstrap( - expectation: CmxIrohClientOfflinePolicyExpectation?, - confirmedLocalBinding: CmxIrohBrokerBinding? - ) async throws -> CmxIrohClientOfflineBootstrap? { - guard let offlinePolicyCache, let expectation else { return nil } - return try await offlinePolicyCache.loadBootstrap( - for: expectation, - confirmedLocalBinding: confirmedLocalBinding, - now: now() - ) - } - - func install( - policy: ResolvedPolicy, - revision: UInt64, - startRelays: Bool - ) async throws { - try requireCurrent(revision) - let offlinePolicy = try policy.offlineExpectation.map { expectation in - guard let offlinePolicyCache else { - throw CmxIrohClientOfflinePolicyCacheError.policyMismatch - } - return try CmxIrohClientOfflinePolicyContext( - cache: offlinePolicyCache, - expectation: expectation, - localBinding: policy.binding - ) - } - let provider: CmxIrohRegistryContextProvider - if let registryContextProvider { - await registryContextProvider.updatePolicy( - localBindingExpectation: policy.expectation, - managedRelayURLs: managedRelayURLs, - allowedRouteRelayURLs: endpointRelayProfile.allowedRelayURLs, - offlinePolicy: offlinePolicy - ) - provider = registryContextProvider - } else { - provider = CmxIrohRegistryContextProvider( - supervisor: supervisor, - broker: broker, - localBindingExpectation: policy.expectation, - managedRelayURLs: managedRelayURLs, - allowedRouteRelayURLs: endpointRelayProfile.allowedRelayURLs, - networkPathSnapshot: networkPathSnapshot, - offlinePolicy: offlinePolicy, - lanFallback: lanFallback, - customPrivateFallback: customPrivateFallback, - now: now - ) - registryContextProvider = provider - } - await contextRouter.install(provider) - localBinding = policy.binding - - guard endpointRelayProfile.source == .managed, - !endpointRelayProfile.allowedRelayURLs.isEmpty else { - await relayCoordinator?.deactivate() - relayCoordinator = nil - return - } - - let coordinator: CmxIrohRelayCredentialCoordinator - if let relayCoordinator { - coordinator = relayCoordinator - } else { - coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: endpointRelayProfile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, policy.binding) - } - ) - relayCoordinator = coordinator - } - - let bootstrap = startRelays ? configuration.cachedRelayCredential : nil - if startRelays || bootstrap != nil { - do { - try await coordinator.activate( - bindingID: policy.binding.bindingID, - endpointIdentity: policy.binding.endpointID, - bootstrap: bootstrap - ) - } catch { - // Registration remains authoritative; direct paths remain usable. - } - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift deleted file mode 100644 index 5ceca839..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+PolicyRefresh.swift +++ /dev/null @@ -1,155 +0,0 @@ -internal import CMUXMobileCore -internal import Foundation - -extension CmxIrohClientRuntime { - func startSupervisorObservation(revision: UInt64) async { - supervisorEventTask?.cancel() - let events = await supervisor.events() - supervisorEventTask = Task { [weak self] in - guard let self else { return } - for await event in events { - guard !Task.isCancelled else { return } - switch event { - case .networkChanged: - await self.handleSupervisorNetworkChange(revision: revision) - case let .recovered(_, newGeneration): - await self.handleSupervisorRecovery( - revision: revision, - runtimeGeneration: newGeneration - ) - case .snapshot: - break - } - } - } - } - - func handleSupervisorRecovery( - revision: UInt64, - runtimeGeneration: UInt64 - ) async { - guard lifecycleRevision == revision, - lifecyclePhase.ownsNetworkOperation else { return } - if lifecyclePhase == .active { - await sessionPool.activate(runtimeGeneration: runtimeGeneration) - } - handleSupervisorNetworkChange(revision: revision) - } - - func handleSupervisorNetworkChange(revision: UInt64) { - guard lifecycleRevision == revision, - lifecyclePhase.ownsNetworkOperation else { return } - guard registrationRefreshEnabled else { - registrationRefreshPending = true - return - } - scheduleRegistrationRefresh(revision: revision) - } - - func scheduleRegistrationRefresh(revision: UInt64) { - guard lifecyclePhase == .active, - lifecycleRevision == revision else { return } - guard registrationRefreshTask == nil else { - registrationRefreshPending = true - return - } - registrationRefreshPending = false - let refreshID = UUID() - registrationRefreshTaskID = refreshID - registrationRefreshTask = Task { [weak self] in - guard let self else { return .failed(.superseded) } - return try await self.refreshRegistration( - revision: revision, - refreshID: refreshID - ) - } - } - - func refreshRegistration( - revision: UInt64, - refreshID: UUID - ) async throws -> CmxIrohLiveDiscoveryRefreshOutcome { - defer { - if lifecycleRevision == revision, - registrationRefreshTaskID == refreshID { - registrationRefreshTask = nil - registrationRefreshTaskID = nil - if registrationRefreshEnabled, - registrationRefreshPending, - lifecyclePhase == .active { - scheduleRegistrationRefresh(revision: revision) - } - } - } - guard lifecyclePhase == .active, - lifecycleRevision == revision else { - return .failed(.superseded) - } - guard let previousBinding = localBinding else { - return .failed(.endpointUnavailable) - } - do { - let endpoint = try await supervisor.activeEndpoint() - let endpointID = await endpoint.identity() - let policy = try await resolvePolicy( - expectedEndpointID: endpointID, - revision: revision - ) - guard policy.binding.bindingID == previousBinding.bindingID else { - throw CmxIrohClientRuntimeError.invalidLocalBinding - } - try await install(policy: policy, revision: revision, startRelays: false) - try requireCurrent(revision) - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .active, - endpointID: endpointID, - bindingID: policy.binding.bindingID - ) - if let registration = policy.registration, - let discovery = policy.discovery { - let published = await handleBinding(registration, discovery) - try requireCurrent(revision) - guard published else { return .failed(.superseded) } - liveDiscoveryGeneration &+= 1 - return .refreshed - } else if let lanRendezvous = policy.cachedLANRendezvous { - await handleCachedBindings(policy.cachedTargetBindings, lanRendezvous) - return .failed(.offline) - } - return .failed(.policyUnavailable) - } catch is CancellationError { - throw CancellationError() - } catch { - guard lifecyclePhase == .active, - lifecycleRevision == revision else { - throw error - } - guard !CmxIrohTrustBrokerClientError - .preservesVerifiedPolicyDuringRefresh(error) else { - // Keep the last exact verified binding while broker availability - // prevents a refresh. - return .failed(DiagnosticFailureKind.classify(error)) - } - lifecyclePhase = .stopping - lifecycleRevision &+= 1 - let failureRevision = lifecycleRevision - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .failed, - endpointID: nil, - bindingID: previousBinding.bindingID - ) - await tearDownNetwork() - guard lifecyclePhase == .stopping, - lifecycleRevision == failureRevision else { - throw error - } - try? await offlinePolicyCache?.deactivate() - await handlePolicyInvalidation() - if lifecyclePhase == .stopping, - lifecycleRevision == failureRevision { - lifecyclePhase = .failed - } - throw error - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift deleted file mode 100644 index d63c0552..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime+RelayPolicy.swift +++ /dev/null @@ -1,125 +0,0 @@ -extension CmxIrohClientRuntime { - /// Installs a resolved relay policy without recreating the endpoint or sessions. - public func replaceRelayPolicy( - _ policy: CmxIrohEffectiveRelayPolicy - ) async throws { - let verifiedManagedURLs = policy.managedPolicy.map { - Set($0.relays.map(\.url)) - } ?? managedRelayURLs - try await replaceRelayProfile( - policy.endpointRelayProfile, - managedRelayURLs: verifiedManagedURLs, - relayBootstrap: policy.relayBootstrap - ) - } - - /// Installs an endpoint relay profile against the current verified managed fleet. - public func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile - ) async throws { - try await replaceRelayProfile( - profile, - managedRelayURLs: managedRelayURLs, - relayBootstrap: nil - ) - } - - private func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile, - managedRelayURLs replacementManagedURLs: Set<String>, - relayBootstrap: CmxIrohRelayTokenResponse? - ) async throws { - guard lifecyclePhase == .active, let binding = localBinding else { - throw CmxIrohClientRuntimeError.inactive - } - guard (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - replacementManagedURLs.count - ), - profile.source == .custom - || profile.allowedRelayURLs.isSubset(of: replacementManagedURLs) else { - throw CmxIrohClientRuntimeError.relayFleetMismatch - } - let revision = lifecycleRevision - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: binding.endpointID - ) - try requireCurrent(revision) - - managedRelayURLs = replacementManagedURLs - endpointRelayProfile = profile - let expectation = try CmxIrohLocalBindingExpectation( - deviceID: binding.deviceID, - appInstanceID: binding.appInstanceID, - tag: binding.tag, - platform: binding.platform, - endpointID: binding.endpointID, - identityGeneration: binding.identityGeneration, - pairingEnabled: binding.pairingEnabled, - capabilities: binding.capabilities - ) - let offlinePolicy = try offlinePolicyCache.map { cache in - let offlineExpectation = try CmxIrohClientOfflinePolicyExpectation( - accountID: configuration.accountID, - localBindingExpectation: expectation, - managedRelayURLs: replacementManagedURLs - ) - return try CmxIrohClientOfflinePolicyContext( - cache: cache, - expectation: offlineExpectation, - localBinding: binding - ) - } - let provider: CmxIrohRegistryContextProvider - if let registryContextProvider { - await registryContextProvider.updatePolicy( - localBindingExpectation: expectation, - managedRelayURLs: replacementManagedURLs, - allowedRouteRelayURLs: profile.allowedRelayURLs, - offlinePolicy: offlinePolicy - ) - provider = registryContextProvider - } else { - provider = CmxIrohRegistryContextProvider( - supervisor: supervisor, - broker: broker, - localBindingExpectation: expectation, - managedRelayURLs: replacementManagedURLs, - allowedRouteRelayURLs: profile.allowedRelayURLs, - networkPathSnapshot: networkPathSnapshot, - offlinePolicy: offlinePolicy, - lanFallback: lanFallback, - customPrivateFallback: customPrivateFallback, - now: now - ) - registryContextProvider = provider - } - await contextRouter.install(provider) - try requireCurrent(revision) - - await relayCoordinator?.deactivate() - relayCoordinator = nil - guard profile.source == .managed, - !profile.allowedRelayURLs.isEmpty else { return } - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: replacementManagedURLs, - selectedRelayURLs: profile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = coordinator - do { - try await coordinator.activate( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - bootstrap: relayBootstrap - ) - } catch { - // The verified allowlist is already live; direct paths remain usable - // while the coordinator retries a managed credential refresh. - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift deleted file mode 100644 index 12fac0f3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +++ /dev/null @@ -1,527 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Owns one account-and-build-scoped iOS endpoint and its verified broker policy. -public actor CmxIrohClientRuntime { - /// Runs after a registration and exact discovery response have been verified. - public typealias BindingHandler = @Sendable ( - _ registration: CmxIrohRegistrationResponse, - _ discovery: CmxIrohDiscoveryResponse - ) async -> Bool - - /// Runs when connectivity-only startup restores signed, already-known Mac tuples. - public typealias CachedBindingsHandler = @Sendable ( - _ bindings: [CmxIrohBrokerBinding], - _ lanRendezvous: CmxIrohLANRendezvous - ) async -> Void - - /// Supplies local-link reachability only for one authenticated Mac tuple. - public typealias LANFallbackProvider = CmxIrohRegistryContextProvider.LANFallbackProvider - public typealias CustomPrivateFallbackProvider = - CmxIrohRegistryContextProvider.CustomPrivateFallbackProvider - - /// Runs after a relay credential is installed on the exact active binding. - public typealias RelayCredentialHandler = @Sendable ( - _ response: CmxIrohRelayTokenResponse, - _ binding: CmxIrohBrokerBinding - ) async -> Void - - /// Removes account-local identity, binding, relay, and route cache state. - public typealias LocalDeactivationHandler = @Sendable () async -> Void - - /// Removes persisted binding and route state after terminal broker evidence. - public typealias PolicyInvalidationHandler = @Sendable () async -> Void - - struct ResolvedPolicy: Sendable { - let registration: CmxIrohRegistrationResponse? - let discovery: CmxIrohDiscoveryResponse? - let binding: CmxIrohBrokerBinding - let expectation: CmxIrohLocalBindingExpectation - let offlineExpectation: CmxIrohClientOfflinePolicyExpectation? - let cachedTargetBindings: [CmxIrohBrokerBinding] - let cachedLANRendezvous: CmxIrohLANRendezvous? - } - - enum LifecyclePhase: Equatable, Sendable { - case inactive - case starting - case active - case stopping - case signingOut - case quarantined - case failed - - var allowsStart: Bool { - self == .inactive || self == .failed - } - - var ownsNetworkOperation: Bool { - self == .starting || self == .active - } - } - - /// The route-aware factory registered by the iOS app before fallback transports. - public nonisolated let transportFactory: CmxIrohByteTransportFactory - - let supervisor: CmxIrohEndpointSupervisor - let contextRouter: CmxIrohRuntimeContextRouter - let sessionPool: CmxIrohClientSessionPool - let broker: any CmxIrohClientBrokerServing - let configuration: CmxIrohClientRuntimeConfiguration - var endpointRelayProfile: CmxIrohEndpointRelayProfile - var managedRelayURLs: Set<String> - let pendingRevocations: CmxIrohPendingRevocationOutbox - let protocolConfiguration: CmxIrohProtocolConfiguration - let offlinePolicyCache: CmxIrohClientOfflinePolicyCache? - let networkPathSnapshot: @Sendable () async throws -> CmxIrohNetworkPathSnapshot - let lanFallback: LANFallbackProvider? - let customPrivateFallback: CustomPrivateFallbackProvider? - let now: @Sendable () -> Date - let handleBinding: BindingHandler - let handleCachedBindings: CachedBindingsHandler - let handleRelayCredential: RelayCredentialHandler - let handleLocalDeactivation: LocalDeactivationHandler - let handlePolicyInvalidation: PolicyInvalidationHandler - - var lifecycleRevision: UInt64 = 0 - var lifecyclePhase = LifecyclePhase.inactive - var signOutOperation: Task<CmxIrohClientSignOutPreparation, Never>? - var relayCoordinator: CmxIrohRelayCredentialCoordinator? - var supervisorEventTask: Task<Void, Never>? - var registrationRefreshTask: Task<CmxIrohLiveDiscoveryRefreshOutcome, any Error>? - var registrationRefreshTaskID: UUID? - var registrationRefreshPending = false - var registrationRefreshEnabled = false - var liveDiscoveryGeneration: UInt64 = 0 - var localBinding: CmxIrohBrokerBinding? - var registryContextProvider: CmxIrohRegistryContextProvider? - var currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .inactive, - endpointID: nil, - bindingID: nil - ) - - /// Creates an inactive iOS runtime and its stable deferred transport factory. - /// - /// The endpoint is not bound until ``start()``. The exposed - /// ``transportFactory`` rejects dials until registration and discovery have - /// installed one exact ``CmxIrohLocalBindingExpectation``. - /// - /// - Parameters: - /// - factory: The production Iroh binding or a test endpoint factory. - /// - broker: The authenticated registration, discovery, grant, and relay client. - /// - configuration: Stable account-and-build-scoped endpoint inputs. - /// - pendingRevocations: Device-only bindings that must be revoked before registration. - /// - protocolConfiguration: The cmux ALPN and stream framing configuration. - /// - diagnosticLog: Optional privacy-safe lifecycle sink for pooled sessions. - /// - networkPathSnapshot: A generation-aware view of positively identified - /// private-network profiles. An empty profile set disables explicit hints. - /// - now: Wall-clock injection for route and relay validation. - /// - handleBinding: Persists the exact verified binding and discovery state. - /// - handleRelayCredential: Persists an installed relay credential. - /// - handleLocalDeactivation: Wipes account-local Iroh caches during sign-out. - /// - handlePolicyInvalidation: Clears persisted broker routes after a terminal refresh. - /// - Throws: An endpoint configuration error for an invalid cached relay set. - public init( - factory: any CmxIrohEndpointFactory, - broker: any CmxIrohClientBrokerServing, - configuration: CmxIrohClientRuntimeConfiguration, - pendingRevocations: CmxIrohPendingRevocationOutbox, - protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1, - diagnosticLog: DiagnosticLog? = nil, - offlinePolicyCache: CmxIrohClientOfflinePolicyCache? = nil, - networkPathSnapshot: @escaping @Sendable () async throws -> CmxIrohNetworkPathSnapshot = { - CmxIrohNetworkPathSnapshot(generation: 1, activeNetworkProfiles: []) - }, - lanFallback: LANFallbackProvider? = nil, - customPrivateFallback: CustomPrivateFallbackProvider? = nil, - now: @escaping @Sendable () -> Date = { Date() }, - handleBinding: @escaping BindingHandler = { _, _ in true }, - handleCachedBindings: @escaping CachedBindingsHandler = { _, _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, - handleLocalDeactivation: @escaping LocalDeactivationHandler = {}, - handlePolicyInvalidation: @escaping PolicyInvalidationHandler = {} - ) throws { - let endpointRelayProfile = try configuration.resolvedEndpointRelayProfile( - now: now() - ) - let endpointConfiguration = CmxIrohEndpointConfiguration( - secretKey: configuration.identity.secretKey, - alpns: [protocolConfiguration.alpn], - relayProfile: endpointRelayProfile - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration - ) - let contextRouter = CmxIrohRuntimeContextRouter() - let sessionPool = CmxIrohClientSessionPool( - supervisor: supervisor, - contextProvider: contextRouter, - protocolConfiguration: protocolConfiguration, - diagnosticLog: diagnosticLog - ) - self.supervisor = supervisor - self.contextRouter = contextRouter - self.sessionPool = sessionPool - self.broker = broker - self.configuration = configuration - self.endpointRelayProfile = endpointRelayProfile - managedRelayURLs = configuration.managedRelayURLs - self.pendingRevocations = pendingRevocations - self.protocolConfiguration = protocolConfiguration - self.offlinePolicyCache = offlinePolicyCache - self.networkPathSnapshot = networkPathSnapshot - self.lanFallback = lanFallback - self.customPrivateFallback = customPrivateFallback - self.now = now - self.handleBinding = handleBinding - self.handleCachedBindings = handleCachedBindings - self.handleRelayCredential = handleRelayCredential - self.handleLocalDeactivation = handleLocalDeactivation - self.handlePolicyInvalidation = handlePolicyInvalidation - transportFactory = CmxIrohByteTransportFactory(sessionPool: sessionPool) - } - - /// Returns the current non-secret lifecycle snapshot. - public func snapshot() -> CmxIrohClientRuntimeSnapshot { - currentSnapshot - } - - /// Monotonic count of online broker snapshots verified by this runtime. - public func liveDiscoverySnapshotGeneration() -> UInt64 { - liveDiscoveryGeneration - } - - /// Refreshes registration and discovery, returning true only when a new - /// online broker snapshot was verified and installed. - /// - /// Connectivity fallback may preserve an existing verified runtime for - /// already-paired Macs, but returns false here so a cached or stale snapshot - /// can never authorize a first pairing. - public func refreshLiveDiscovery() async -> Bool { - await refreshLiveDiscoveryOutcome() == .refreshed - } - - /// Refreshes registration and discovery with a privacy-safe failure reason. - /// - /// Connectivity fallback may preserve the existing verified runtime, but - /// returns a categorical failure so diagnostics can distinguish an offline - /// broker, unavailable policy, inactive endpoint, and superseded lifecycle. - /// Raw errors and their potentially sensitive associated data are discarded. - /// - /// - Returns: Whether a new verified snapshot was installed, or the bounded - /// reason it was not. - public func refreshLiveDiscoveryOutcome() async -> CmxIrohLiveDiscoveryRefreshOutcome { - do { - return try await refreshLiveDiscoveryOutcomeThrowing() - } catch { - return .failed(DiagnosticFailureKind.classify(error)) - } - } - - func refreshLiveDiscoveryThrowing() async throws -> Bool { - try await refreshLiveDiscoveryOutcomeThrowing() == .refreshed - } - - private func refreshLiveDiscoveryOutcomeThrowing() async throws - -> CmxIrohLiveDiscoveryRefreshOutcome - { - guard lifecyclePhase == .active else { - return .failed(.endpointUnavailable) - } - let priorGeneration = liveDiscoveryGeneration - var mayScheduleFreshRequest = registrationRefreshTask != nil - var latestOutcome: CmxIrohLiveDiscoveryRefreshOutcome = .failed(.superseded) - if registrationRefreshTask == nil { - scheduleRegistrationRefresh(revision: lifecycleRevision) - } - var lastAwaitedTaskID: UUID? - while lifecyclePhase == .active, - let refresh = registrationRefreshTask, - let refreshID = registrationRefreshTaskID, - refreshID != lastAwaitedTaskID { - lastAwaitedTaskID = refreshID - latestOutcome = try await refresh.value - guard lifecyclePhase == .active else { - return .failed(.endpointUnavailable) - } - if liveDiscoveryGeneration > priorGeneration { return .refreshed } - if registrationRefreshTaskID != nil { - mayScheduleFreshRequest = false - continue - } - guard mayScheduleFreshRequest else { return latestOutcome } - mayScheduleFreshRequest = false - scheduleRegistrationRefresh(revision: lifecycleRevision) - } - return lifecyclePhase == .active - ? latestOutcome - : .failed(.endpointUnavailable) - } - - /// Returns the selected live path after removing raw transport coordinates. - /// - /// Relay attribution succeeds only when the selected relay is present in - /// the exact verified effective policy installed by the composition root. - /// - /// - Parameter relayPolicy: The current verified effective relay policy. - /// - Returns: A credential-free path category safe for settings and diagnostics. - public func selectedTransportPath( - relayPolicy: CmxIrohEffectiveRelayPolicy? - ) async -> CmxIrohSelectedTransportPath { - let observed = await sessionPool.selectedObservedPath() - return CmxIrohSelectedTransportPathClassifier(policy: relayPolicy) - .classify(observed) - } - - /// Emits when connection lifecycle changes may alter the selected path. - /// - /// Consumers re-read ``selectedTransportPath(relayPolicy:)`` for the - /// credential-free value. The stream never carries raw path data. - public func selectedTransportPathChanges() async -> AsyncStream<Void> { - await sessionPool.selectedPathChanges() - } - - /// Binds the endpoint, registers it, and installs exact discovery and relay policy. - /// - /// - Throws: A bind, broker, signature, fleet, or local-binding validation error. - public func start() async throws { - guard lifecyclePhase.allowsStart else { - throw CmxIrohClientRuntimeError.alreadyActive - } - lifecyclePhase = .starting - lifecycleRevision &+= 1 - let revision = lifecycleRevision - registrationRefreshPending = false - registrationRefreshEnabled = false - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .starting, - endpointID: nil, - bindingID: nil - ) - - do { - let startingRelayProfile = try endpointRelayProfile - .droppingExpiredManagedCredentials(at: now()) - if startingRelayProfile != endpointRelayProfile { - try await supervisor.replaceRelayProfile(startingRelayProfile) - endpointRelayProfile = startingRelayProfile - } - await startSupervisorObservation(revision: revision) - let endpointSnapshot = try await supervisor.activate() - try requireCurrent(revision) - guard let endpointID = endpointSnapshot.identity else { - throw CmxIrohClientRuntimeError.invalidLocalBinding - } - let policy = try await resolvePolicy( - expectedEndpointID: endpointID, - revision: revision - ) - try requireCurrent(revision) - await sessionPool.activate( - runtimeGeneration: endpointSnapshot.runtimeGeneration - ) - try await install(policy: policy, revision: revision, startRelays: true) - if !protocolConfiguration.allowsNATTraversalAfterAdmission { - guard await supervisor.hasConfiguredRelay() else { - throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut - } - try await supervisor.waitForUsableHomeRelay() - try requireCurrent(revision) - } - lifecyclePhase = .active - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .active, - endpointID: endpointID, - bindingID: policy.binding.bindingID - ) - if let registration = policy.registration, - let discovery = policy.discovery { - let published = await handleBinding(registration, discovery) - try requireCurrent(revision) - if published { liveDiscoveryGeneration &+= 1 } - } else if let lanRendezvous = policy.cachedLANRendezvous { - await handleCachedBindings(policy.cachedTargetBindings, lanRendezvous) - } - registrationRefreshEnabled = true - if registrationRefreshPending { - registrationRefreshPending = false - scheduleRegistrationRefresh(revision: revision) - } - } catch { - guard lifecyclePhase == .starting, - lifecycleRevision == revision else { - throw error - } - lifecyclePhase = .stopping - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .failed, - endpointID: nil, - bindingID: localBinding?.bindingID - ) - await tearDownNetwork() - if lifecyclePhase == .stopping, - lifecycleRevision == revision { - lifecyclePhase = .failed - } - throw error - } - } - - /// Records a background transition without closing the endpoint or streams. - /// - /// iOS may suspend the process immediately, so the runtime deliberately - /// performs no network or persistence work on this transition. - public func didEnterBackground() { - // Endpoint ownership is process-scoped and survives ordinary suspension. - } - - /// Health-checks the preserved endpoint and refreshes its signed registration. - /// - /// A healthy generation is reused. A stale driver is recreated with the - /// same secret key before registration is refreshed. - /// - /// - Throws: A replacement-bind or terminal policy-refresh error. Connectivity - /// failure keeps the last verified local policy for a later retry. - public func didBecomeActive() async throws { - guard lifecyclePhase == .active else { return } - let revision = lifecycleRevision - // A registration refresh reads the active endpoint. Keep the preserved - // generation installed until any existing refresh finishes, then pause - // new refreshes across the brief unbound window used for stale-driver - // replacement. Supervisor events become one pending refresh that the - // explicit foreground refresh below consumes. - registrationRefreshEnabled = false - do { - if let refresh = registrationRefreshTask { - _ = try await refresh.value - try requireCurrent(revision) - } - let checked = try await supervisor.ensureHealthy() - try requireCurrent(revision) - await sessionPool.activate(runtimeGeneration: checked.runtimeGeneration) - try requireCurrent(revision) - registrationRefreshPending = false - registrationRefreshEnabled = true - _ = try await refreshLiveDiscoveryThrowing() - try requireCurrent(revision) - try await relayCoordinator?.refreshIfNeeded() - try requireCurrent(revision) - } catch { - if lifecyclePhase == .active, lifecycleRevision == revision { - registrationRefreshEnabled = true - } - throw error - } - } - - /// Opens a terminal or artifact lane on the admitted pooled peer connection. - /// - /// The same session also carries the existing RPC control lane, avoiding a - /// second QUIC handshake and preserving Iroh stream prioritization. - /// - /// - Parameters: - /// - request: The exact Iroh route and intended Mac device binding. - /// - lane: A terminal or artifact lane declaration. - /// - priority: Iroh's relative stream priority. - /// - Returns: The stream after its authenticated lane header is written. - /// - Throws: A lifecycle, discovery, admission, or stream-framing error. - public func openBidirectionalLane( - for request: CmxByteTransportRequest, - lane: CmxIrohLane, - priority: Int32 - ) async throws -> CmxIrohBidirectionalStream { - guard lifecyclePhase == .active else { - throw CmxIrohClientRuntimeError.inactive - } - return try await sessionPool.openBidirectionalLane( - for: request, - lane: lane, - priority: priority - ) - } - - /// Starts the one client-owned server-event accept loop for this peer. - public func serverEventByteStream( - for request: CmxByteTransportRequest - ) async throws -> CmxIndependentEventByteStream { - guard lifecyclePhase == .active else { - throw CmxIrohClientRuntimeError.inactive - } - return try await sessionPool.serverEventByteStream(for: request) - } - - /// Invalidates one peer session after a lane reports a terminal connection error. - /// - /// The next control or lane operation performs fresh discovery and admission. - /// - /// - Parameter request: The exact peer intent whose pooled connection failed. - public func invalidateSession(for request: CmxByteTransportRequest) async { - await sessionPool.invalidate(for: request) - } - - /// Stops network ownership while preserving account-scoped persistence. - public func stop() async { - guard lifecyclePhase == .starting || lifecyclePhase == .active else { - return - } - lifecyclePhase = .stopping - lifecycleRevision &+= 1 - let revision = lifecycleRevision - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .stopping, - endpointID: currentSnapshot.endpointID, - bindingID: localBinding?.bindingID - ) - await tearDownNetwork() - guard lifecyclePhase == .stopping, - lifecycleRevision == revision else { return } - lifecyclePhase = .inactive - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .inactive, - endpointID: nil, - bindingID: nil - ) - } - - /// Closes networking, durably queues revocation, then deactivates local state. - /// - /// The binding is captured and the lifecycle enters `signingOut` before the - /// first suspension. Endpoint teardown and device-only persistence run - /// concurrently. Persistence failure leaves the closed runtime quarantined, - /// retains the binding, and skips every local identity deactivation hook. - /// Calling this method again while quarantined retries the durable enqueue. - /// - /// - Returns: The prior binding and whether it was durably queued. - public func deactivateForSignOut() async -> CmxIrohClientSignOutPreparation { - if let signOutOperation { - return await signOutOperation.value - } - let pendingRevocation = localBinding.flatMap { binding in - try? CmxIrohPendingRevocation( - accountID: configuration.accountID, - tag: configuration.tag, - bindingID: binding.bindingID - ) - } - lifecyclePhase = .signingOut - lifecycleRevision &+= 1 - let revision = lifecycleRevision - currentSnapshot = CmxIrohClientRuntimeSnapshot( - state: .signingOut, - endpointID: currentSnapshot.endpointID, - bindingID: pendingRevocation?.bindingID - ) - - let operation = Task { - await self.performSignOut( - pendingRevocation: pendingRevocation, - revision: revision - ) - } - signOutOperation = operation - return await operation.value - } - -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift deleted file mode 100644 index a9d192bc..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeConfiguration.swift +++ /dev/null @@ -1,75 +0,0 @@ -internal import CMUXMobileCore - -/// Stable, account-and-build-scoped inputs for one iOS Iroh lifecycle. -public struct CmxIrohClientRuntimeConfiguration: Equatable, Sendable { - /// The authenticated account scope used only for device-local policy isolation. - public let accountID: String - - /// The app-generated UUID shared with the cmux device registry. - public let deviceID: String - - /// The account-and-build-scoped app-instance UUID. - public let appInstanceID: String - - /// The release channel or tagged-build scope registered with the broker. - public let tag: String - - /// The optional user-visible device name. - public let displayName: String? - - /// The stable endpoint key and monotonic rotation generation. - public let identity: CmxIrohIdentityMaterial - - /// The bounded application capabilities advertised by this endpoint. - public let capabilities: [String] - - /// The complete relay fleet trusted by this app build. - public let managedRelayURLs: Set<String> - - /// Optional selected-managed or strict-custom profile for the local endpoint. - /// - /// `nil` preserves automatic use of the complete managed fleet. - public let endpointRelayProfile: CmxIrohEndpointRelayProfile? - - /// A previously validated endpoint-scoped relay credential, when available. - public let cachedRelayCredential: CmxIrohRelayTokenResponse? - - /// Creates an immutable iOS client lifecycle configuration. - /// - /// Broker-facing validation occurs when ``CmxIrohClientRuntime/start()`` - /// creates the signed registration payload. - /// - /// - Parameters: - /// - deviceID: The app-generated lowercase device UUID. - /// - appInstanceID: The account-and-build-scoped lowercase UUID. - /// - tag: The safe release or tagged-build scope. - /// - displayName: An optional user-visible device name. - /// - identity: The account-scoped endpoint identity material. - /// - capabilities: The advertised protocol capabilities. - /// - managedRelayURLs: The exact managed relay fleet. - /// - endpointRelayProfile: An optional local selection or custom override. - /// - cachedRelayCredential: A validated cached relay capability. - public init( - accountID: String, - deviceID: String, - appInstanceID: String, - tag: String, - displayName: String?, - identity: CmxIrohIdentityMaterial, - capabilities: [String], - managedRelayURLs: Set<String>, - endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, - cachedRelayCredential: CmxIrohRelayTokenResponse? = nil - ) { - self.accountID = accountID - self.deviceID = cmxCanonicalDeviceID(deviceID) - self.appInstanceID = appInstanceID.lowercased() - self.tag = tag - self.displayName = displayName - self.identity = identity - self.capabilities = capabilities - self.managedRelayURLs = managedRelayURLs - self.endpointRelayProfile = endpointRelayProfile - self.cachedRelayCredential = cachedRelayCredential - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeError.swift deleted file mode 100644 index 7ccee72f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeError.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Fail-closed iOS Iroh runtime errors. -public enum CmxIrohClientRuntimeError: Error, Equatable, Sendable { - /// Activation was requested while this runtime already owns an endpoint. - case alreadyActive - - /// A lifecycle operation requires an active endpoint. - case inactive - - /// Registration or discovery substituted any local binding field. - case invalidLocalBinding - - /// Discovery omitted the exact binding returned by registration. - case localBindingMissingFromDiscovery - - /// Broker policy named a relay fleet different from the app allowlist. - case relayFleetMismatch - - /// Broker and app disagree on the route-contract version. - case routeContractMismatch - - /// A later lifecycle generation superseded the current operation. - case superseded -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeSnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeSnapshot.swift deleted file mode 100644 index b67d7df5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeSnapshot.swift +++ /dev/null @@ -1,29 +0,0 @@ -public import CMUXMobileCore - -/// Non-secret iOS Iroh runtime state suitable for diagnostics. -public struct CmxIrohClientRuntimeSnapshot: Equatable, Sendable { - /// The current lifecycle phase. - public let state: CmxIrohClientRuntimeState - - /// The stable local endpoint identity while active. - public let endpointID: CmxIrohPeerIdentity? - - /// The broker binding currently authorizing this endpoint. - public let bindingID: String? - - /// Creates a non-sensitive runtime snapshot. - /// - /// - Parameters: - /// - state: The current lifecycle phase. - /// - endpointID: The active endpoint identity, when available. - /// - bindingID: The exact active broker binding, when available. - public init( - state: CmxIrohClientRuntimeState, - endpointID: CmxIrohPeerIdentity?, - bindingID: String? - ) { - self.state = state - self.endpointID = endpointID - self.bindingID = bindingID - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeState.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeState.swift deleted file mode 100644 index 445afc01..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntimeState.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// The non-sensitive lifecycle state of an iOS Iroh runtime. -public enum CmxIrohClientRuntimeState: Equatable, Sendable { - /// No endpoint or binding is active. - case inactive - - /// The endpoint is binding or broker policy is being verified. - case starting - - /// The endpoint and exact local broker binding are active. - case active - - /// Ordinary stop has claimed lifecycle ownership and is closing networking. - case stopping - - /// Sign-out is closing networking and durably queuing binding revocation. - case signingOut - - /// Networking is closed, but local identity state is retained until revocation is queued. - case quarantined - - /// Activation failed and local network resources were closed. - case failed -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientServerEventReceiver.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientServerEventReceiver.swift deleted file mode 100644 index 6048458a..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientServerEventReceiver.swift +++ /dev/null @@ -1,278 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Fail-closed lifecycle and framing errors for the client-owned server-event -/// accept loop. -public enum CmxIrohClientServerEventReceiverError: Error, Equatable, Sendable { - case consumerAlreadyActive - case alreadyClosed - case headerTimedOut - case unexpectedEndOfStream - case backpressureExceeded -} - -/// Owns the sole unidirectional-stream accept loop for one admitted client -/// connection and exposes only payload bytes from `serverEvents` lanes. -/// -/// Unknown or malformed lanes are stopped without affecting the control -/// stream. One bounded stream is exposed to the RPC frame decoder, preventing -/// feature code from creating competing QUIC accept loops. -public actor CmxIrohClientServerEventReceiver { - private struct ActiveConsumer: Sendable { - let id: UUID - let continuation: CmxIndependentEventByteStream.Continuation - var task: Task<Void, Never>? - var currentStream: (any CmxIrohReceiveStream)? - } - - private struct DecodedHeader: Sendable { - let header: CmxIrohStreamHeader - let trailingBytes: Data - } - - private let connection: any CmxIrohConnection - private let headerCodec: CmxIrohStreamHeaderCodec - private let clock: any CmxIrohRelayClock - private let headerTimeout: TimeInterval - private let maximumReadByteCount: Int - private var activeConsumer: ActiveConsumer? - private var closed = false - private var incomingStreamCreditEnabled = false - - public init( - connection: any CmxIrohConnection, - protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - headerTimeout: TimeInterval = 5, - maximumReadByteCount: Int = 64 * 1_024 - ) throws { - self.connection = connection - self.headerCodec = try CmxIrohStreamHeaderCodec( - configuration: protocolConfiguration - ) - self.clock = clock - self.headerTimeout = headerTimeout - self.maximumReadByteCount = maximumReadByteCount - } - - /// Starts the exact one accept owner and grants credit for one concurrent - /// peer-created unidirectional stream. - public func byteStream() async throws -> CmxIndependentEventByteStream { - guard !closed else { - throw CmxIrohClientServerEventReceiverError.alreadyClosed - } - guard activeConsumer == nil else { - throw CmxIrohClientServerEventReceiverError.consumerAlreadyActive - } - - try await connection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 0, - maximumUnidirectionalStreamCount: 1 - ) - incomingStreamCreditEnabled = true - - let consumerID = UUID() - let pair = CmxIndependentEventByteStream.makeStream( - bufferingPolicy: .bufferingNewest(32) - ) - pair.continuation.onTermination = { [weak self] _ in - guard let self else { return } - Task { await self.cancelConsumer(id: consumerID) } - } - activeConsumer = ActiveConsumer( - id: consumerID, - continuation: pair.continuation, - task: nil, - currentStream: nil - ) - let task = Task { [weak self] in - guard let self else { return } - await self.runAcceptLoop(consumerID: consumerID) - } - if activeConsumer?.id == consumerID { - activeConsumer?.task = task - } else { - task.cancel() - } - return pair.stream - } - - /// Revokes peer stream credit and cancels accept, header, and payload reads. - public func close() async { - guard !closed else { return } - closed = true - await finishConsumer( - id: activeConsumer?.id, - error: CancellationError() - ) - await revokeIncomingStreamCredit() - } - - private func runAcceptLoop(consumerID: UUID) async { - do { - while !Task.isCancelled { - let receiveStream = try await connection.acceptReceiveStream() - guard setCurrentStream(receiveStream, for: consumerID) else { - await receiveStream.stop(errorCode: 1) - throw CancellationError() - } - - let decoded: DecodedHeader - do { - decoded = try await readHeaderWithDeadline(from: receiveStream) - } catch { - await receiveStream.stop(errorCode: 1) - clearCurrentStream(receiveStream, for: consumerID) - try Task.checkCancellation() - continue - } - - guard case .serverEvents = decoded.header.lane else { - await receiveStream.stop(errorCode: 1) - clearCurrentStream(receiveStream, for: consumerID) - continue - } - - if !decoded.trailingBytes.isEmpty { - try yield(decoded.trailingBytes, consumerID: consumerID) - } - while !Task.isCancelled { - guard let bytes = try await receiveStream.receive( - maximumByteCount: maximumReadByteCount - ) else { - break - } - guard !bytes.isEmpty else { continue } - try yield(bytes, consumerID: consumerID) - } - clearCurrentStream(receiveStream, for: consumerID) - } - throw CancellationError() - } catch { - await finishConsumer(id: consumerID, error: error) - } - } - - private func readHeaderWithDeadline( - from receiveStream: any CmxIrohReceiveStream - ) async throws -> DecodedHeader { - let codec = headerCodec - let deadline = clock.now().addingTimeInterval(headerTimeout) - let clock = clock - return try await withThrowingTaskGroup(of: DecodedHeader.self) { group in - group.addTask { - try await Self.readHeader(from: receiveStream, codec: codec) - } - group.addTask { - try await clock.sleep(until: deadline) - await receiveStream.stop(errorCode: 1) - throw CmxIrohClientServerEventReceiverError.headerTimedOut - } - defer { group.cancelAll() } - guard let result = try await group.next() else { - throw CmxIrohClientServerEventReceiverError.unexpectedEndOfStream - } - return result - } - } - - private static func readHeader( - from receiveStream: any CmxIrohReceiveStream, - codec: CmxIrohStreamHeaderCodec - ) async throws -> DecodedHeader { - var buffer = Data() - var requestedByteCount = 16 - while true { - if buffer.count >= requestedByteCount { - do { - let decoded = try codec.decodePrefix(buffer) - return DecodedHeader( - header: decoded.header, - trailingBytes: Data( - buffer.dropFirst(decoded.consumedByteCount) - ) - ) - } catch let error as CmxIrohStreamHeaderCodecError { - if case let .incompleteFrame(requiredByteCount) = error { - requestedByteCount = requiredByteCount - } else { - throw error - } - } - } - let remaining = requestedByteCount - buffer.count - guard let bytes = try await receiveStream.receive( - maximumByteCount: remaining - ), !bytes.isEmpty else { - throw CmxIrohClientServerEventReceiverError.unexpectedEndOfStream - } - buffer.append(bytes) - } - } - - private func yield(_ bytes: Data, consumerID: UUID) throws { - guard let activeConsumer, activeConsumer.id == consumerID else { - throw CancellationError() - } - switch activeConsumer.continuation.yield(bytes) { - case .enqueued: - return - case .dropped: - throw CmxIrohClientServerEventReceiverError.backpressureExceeded - case .terminated: - throw CancellationError() - @unknown default: - throw CmxIrohClientServerEventReceiverError.backpressureExceeded - } - } - - private func setCurrentStream( - _ stream: any CmxIrohReceiveStream, - for consumerID: UUID - ) -> Bool { - guard activeConsumer?.id == consumerID else { return false } - activeConsumer?.currentStream = stream - return true - } - - private func clearCurrentStream( - _ stream: any CmxIrohReceiveStream, - for consumerID: UUID - ) { - guard activeConsumer?.id == consumerID else { return } - activeConsumer?.currentStream = nil - } - - private func cancelConsumer(id consumerID: UUID) async { - guard activeConsumer?.id == consumerID else { return } - await finishConsumer(id: consumerID, error: CancellationError()) - } - - private func finishConsumer(id consumerID: UUID?, error: (any Error)?) async { - guard let consumerID, - let activeConsumer, - activeConsumer.id == consumerID else { - return - } - self.activeConsumer = nil - activeConsumer.task?.cancel() - if let currentStream = activeConsumer.currentStream { - await currentStream.stop(errorCode: 1) - } - if let error { - activeConsumer.continuation.finish(throwing: error) - } else { - activeConsumer.continuation.finish() - } - await revokeIncomingStreamCredit() - } - - private func revokeIncomingStreamCredit() async { - guard incomingStreamCreditEnabled else { return } - incomingStreamCreditEnabled = false - try? await connection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 0, - maximumUnidirectionalStreamCount: 0 - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift deleted file mode 100644 index 51f501da..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift +++ /dev/null @@ -1,375 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// An admitted multistream client session over one Iroh QUIC connection. -public actor CmxIrohClientSession { - public typealias PrivateFallbackContextProvider = @Sendable () async throws -> CmxIrohClientContext - - private let endpoint: any CmxIrohEndpoint - private let targetIdentity: CmxIrohPeerIdentity - private let dialPlan: CmxIrohDialPlan - private let credential: CmxIrohAdmissionCredential - private let privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? - private let privateFallbackValidator: (any CmxIrohPrivateFallbackValidating)? - private let privateFallbackContextProvider: PrivateFallbackContextProvider? - private let protocolConfiguration: CmxIrohProtocolConfiguration - private let headerCodec: CmxIrohStreamHeaderCodec - private let admissionCodec = CmxIrohAdmissionAckCodec() - private var connectionTask: Task<CmxIrohConnectedControl, any Error>? - private var connection: (any CmxIrohConnection)? - private var controlStream: CmxIrohBidirectionalStream? - private var serverEventReceiver: CmxIrohClientServerEventReceiver? - private var controlReceiveBuffer = Data() - private var closed = false - - /// Creates a disconnected session with an explicit two-phase dial plan. - /// - /// - Parameters: - /// - endpoint: The active local endpoint generation. - /// - targetIdentity: The exact remote EndpointID expected from QUIC TLS. - /// - dialPlan: Public paths followed by profile-gated private fallback paths. - /// - credential: The backend grant or same-account offline pairing proof. - /// - privateFallbackAuthorization: The generation snapshot that admitted - /// the plan's private hints. - /// - privateFallbackValidator: The provider that can re-read current - /// network state immediately before a private dial. - /// - protocolConfiguration: The ALPN and stream-header limit. - /// - Throws: A stream-codec configuration error. - public init( - endpoint: any CmxIrohEndpoint, - targetIdentity: CmxIrohPeerIdentity, - dialPlan: CmxIrohDialPlan, - credential: CmxIrohAdmissionCredential, - privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? = nil, - privateFallbackValidator: (any CmxIrohPrivateFallbackValidating)? = nil, - privateFallbackContextProvider: PrivateFallbackContextProvider? = nil, - protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1 - ) throws { - self.endpoint = endpoint - self.targetIdentity = targetIdentity - self.dialPlan = dialPlan - self.credential = credential - self.privateFallbackAuthorization = privateFallbackAuthorization - self.privateFallbackValidator = privateFallbackValidator - self.privateFallbackContextProvider = privateFallbackContextProvider - self.protocolConfiguration = protocolConfiguration - headerCodec = try CmxIrohStreamHeaderCodec(configuration: protocolConfiguration) - } - - /// Establishes and admits the control stream, coalescing concurrent callers. - /// - /// - Throws: A transport, framing, identity, admission, or cancellation error. - public func connect() async throws { - guard !closed else { throw CmxIrohClientSessionError.alreadyClosed } - if connection != nil, controlStream != nil { return } - - let task: Task<CmxIrohConnectedControl, any Error> - if let connectionTask { - task = connectionTask - } else { - task = Task { [weak self] in - guard let self else { throw CancellationError() } - return try await self.establishConnection() - } - connectionTask = task - } - - do { - let connected = try await withTaskCancellationHandler(operation: { - try await task.value - }, onCancel: { - task.cancel() - }) - if connection == nil, controlStream == nil { - connection = connected.connection - controlStream = connected.stream - controlReceiveBuffer = connected.initialReceiveBuffer - } - connectionTask = nil - } catch { - connectionTask = nil - throw error - } - } - - /// Reads control-lane bytes after admission framing has been removed. - /// - /// - Parameter maximumByteCount: The positive per-read cap. - /// - Returns: Application bytes, or `nil` after clean peer finish. - /// - Throws: A transport or lifecycle error. - public func receiveControl( - maximumByteCount: Int = 64 * 1_024 - ) async throws -> Data? { - guard maximumByteCount > 0 else { - throw CmxIrohClientSessionError.invalidMaximumByteCount(maximumByteCount) - } - guard !closed else { throw CmxIrohClientSessionError.alreadyClosed } - guard let controlStream else { throw CmxIrohClientSessionError.notConnected } - if !controlReceiveBuffer.isEmpty { - let count = min(maximumByteCount, controlReceiveBuffer.count) - let value = Data(controlReceiveBuffer.prefix(count)) - controlReceiveBuffer.removeFirst(count) - return value - } - return try await controlStream.receiveStream.receive( - maximumByteCount: maximumByteCount - ) - } - - /// Writes application bytes on the admitted control lane. - /// - /// - Parameter data: The complete buffer to send. - /// - Throws: A transport or lifecycle error. - public func sendControl(_ data: Data) async throws { - guard !closed else { throw CmxIrohClientSessionError.alreadyClosed } - guard let controlStream else { throw CmxIrohClientSessionError.notConnected } - try await controlStream.sendStream.send(data) - } - - /// Opens a terminal or artifact bidirectional lane on the admitted connection. - /// - /// - Parameters: - /// - lane: A terminal or artifact lane declaration. - /// - priority: The Iroh relative stream priority selected by the caller. - /// - Returns: The stream after its lane header has been written. - /// - Throws: A transport, framing, or lifecycle error. - public func openBidirectionalLane( - _ lane: CmxIrohLane, - priority: Int32 - ) async throws -> CmxIrohBidirectionalStream { - switch lane { - case .terminal, .artifact: - break - case .control, .serverEvents: - throw CmxIrohClientSessionError.invalidOutgoingLane - } - guard !closed else { throw CmxIrohClientSessionError.alreadyClosed } - guard let connection else { throw CmxIrohClientSessionError.notConnected } - guard protocolConfiguration.maximumConcurrentClientApplicationLaneCount > 0 else { - throw CmxIrohClientSessionError.applicationLanesUnavailable - } - let stream = try await connection.openBidirectionalStream() - do { - try await stream.sendStream.setPriority(priority) - let header = try CmxIrohStreamHeader(lane: lane) - try await stream.sendStream.send(headerCodec.encode(header)) - return stream - } catch { - await stream.sendStream.reset(errorCode: 1) - await stream.receiveStream.stop(errorCode: 1) - throw error - } - } - - /// Starts the session-owned server-event accept loop. This is the only API - /// that can grant peer-created unidirectional stream credit, so feature - /// consumers cannot race over QUIC stream acceptance. - public func serverEventByteStream() async throws -> CmxIndependentEventByteStream { - guard !closed else { throw CmxIrohClientSessionError.alreadyClosed } - guard let connection else { throw CmxIrohClientSessionError.notConnected } - let receiver: CmxIrohClientServerEventReceiver - if let serverEventReceiver { - receiver = serverEventReceiver - } else { - receiver = try CmxIrohClientServerEventReceiver( - connection: connection, - protocolConfiguration: protocolConfiguration - ) - serverEventReceiver = receiver - } - return try await receiver.byteStream() - } - - /// Suspends until the exact admitted QUIC connection closes. - /// - /// The session pool uses this independently of control-lane I/O so a peer or - /// suspended-iOS timeout evicts the stale pooled session before the next RPC. - public func waitUntilClosed() async { - guard let connection else { return } - await connection.waitUntilClosed() - } - - /// Returns whether the admitted QUIC connection already closed. - /// - /// This closes the scheduler gap between Iroh publishing its close reason - /// and the pool's independent closure watcher evicting this session. - func isClosed() async -> Bool { - if closed { return true } - guard let connection else { return false } - return await connection.isClosed() - } - - /// Reads package-private path evidence from the exact admitted connection. - func observedSelectedPath() async -> CmxIrohObservedConnectionPath { - guard let connection = connection as? any CmxIrohConnectionPathInspecting else { - return .unavailable - } - return await connection.observedSelectedPath() - } - - /// Observes path-selection changes without exposing transport coordinates. - func observedSelectedPathChanges() async -> AsyncStream<CmxIrohObservedConnectionPath> { - guard let connection = connection as? any CmxIrohConnectionPathInspecting else { - return AsyncStream { continuation in - continuation.yield(.unavailable) - continuation.finish() - } - } - return await connection.observedSelectedPathChanges() - } - - /// Closes the control stream and complete QUIC connection. - public func close() async { - guard !closed else { return } - closed = true - connectionTask?.cancel() - connectionTask = nil - await serverEventReceiver?.close() - serverEventReceiver = nil - if let controlStream { - await controlStream.sendStream.reset(errorCode: 0) - await controlStream.receiveStream.stop(errorCode: 0) - } - if let connection { - await connection.close(errorCode: 0, reason: "client_closed") - } - controlStream = nil - self.connection = nil - controlReceiveBuffer.removeAll(keepingCapacity: false) - } - - private func establishConnection() async throws -> CmxIrohConnectedControl { - var establishedConnection: (any CmxIrohConnection)? - var publicConnectionError: (any Error)? - if !dialPlan.publicPaths.isEmpty { - do { - establishedConnection = try await endpoint.connect( - to: CmxIrohEndpointAddress( - identity: targetIdentity, - pathHints: dialPlan.publicPaths - ), - alpn: protocolConfiguration.alpn - ) - } catch { - try Task.checkCancellation() - publicConnectionError = error - } - } - if establishedConnection == nil { - let fallbackContext: CmxIrohClientContext - if let privateFallbackContextProvider { - fallbackContext = try await privateFallbackContextProvider() - guard fallbackContext.credential == credential, - fallbackContext.dialPlan.publicPaths == dialPlan.publicPaths else { - throw CmxIrohPrivateFallbackValidationError.authorizationMismatch - } - } else { - fallbackContext = CmxIrohClientContext( - dialPlan: dialPlan, - credential: credential, - privateFallbackAuthorization: privateFallbackAuthorization - ) - } - let fallbackPaths = fallbackContext.dialPlan.privateFallbackPaths - guard !fallbackPaths.isEmpty else { - if let publicConnectionError { throw publicConnectionError } - throw CmxIrohRegistryContextError.dialPlanUnavailable - } - guard let privateFallbackValidator else { - throw CmxIrohPrivateFallbackValidationError.unavailable - } - guard let authorization = fallbackContext.privateFallbackAuthorization, - authorization.pathHints == fallbackPaths else { - throw CmxIrohPrivateFallbackValidationError.authorizationMismatch - } - try await privateFallbackValidator.validatePrivateFallback( - authorization - ) - try Task.checkCancellation() - establishedConnection = try await endpoint.connect( - to: CmxIrohEndpointAddress( - identity: targetIdentity, - pathHints: fallbackPaths - ), - alpn: protocolConfiguration.alpn - ) - } - guard let establishedConnection else { - throw CmxIrohRegistryContextError.dialPlanUnavailable - } - - do { - try Task.checkCancellation() - guard await establishedConnection.remoteIdentity() == targetIdentity else { - throw CmxIrohClientSessionError.remoteIdentityMismatch - } - try await establishedConnection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 0, - maximumUnidirectionalStreamCount: 0 - ) - let stream = try await establishedConnection.openBidirectionalStream() - let header = try CmxIrohStreamHeader( - lane: .control, - credential: credential - ) - try await stream.sendStream.send(headerCodec.encode(header)) - let admission = try await readAdmissionFrame(from: stream.receiveStream) - switch admission.frame { - case .acceptedPendingNatTraversal, .acceptedRelayOnly: - if admission.frame == .acceptedPendingNatTraversal { - try Task.checkCancellation() - try await establishedConnection.authorizeNatTraversal() - } - try Task.checkCancellation() - try await stream.sendStream.send( - admissionCodec.encodeFrame(.clientReady) - ) - let confirmation = try await readAdmissionFrame( - from: stream.receiveStream, - initialBuffer: admission.trailingBytes - ) - switch confirmation.frame { - case .serverReady: - break - case let .denied(code): - throw CmxIrohClientSessionError.admissionDenied(code: code) - case .acceptedPendingNatTraversal, .acceptedRelayOnly, .clientReady: - throw CmxIrohClientSessionError.invalidAdmissionFrame - } - try Task.checkCancellation() - return CmxIrohConnectedControl( - connection: establishedConnection, - stream: stream, - initialReceiveBuffer: confirmation.trailingBytes - ) - case let .denied(code): - throw CmxIrohClientSessionError.admissionDenied(code: code) - case .clientReady, .serverReady: - throw CmxIrohClientSessionError.invalidAdmissionFrame - } - } catch { - await establishedConnection.close(errorCode: 1, reason: "admission_failed") - throw error - } - } - - private func readAdmissionFrame( - from receiveStream: any CmxIrohReceiveStream, - initialBuffer: Data = Data() - ) async throws -> (frame: CmxIrohAdmissionFrame, trailingBytes: Data) { - var buffer = initialBuffer - while buffer.count < CmxIrohAdmissionAckCodec.frameByteCount { - let remaining = CmxIrohAdmissionAckCodec.frameByteCount - buffer.count - guard let bytes = try await receiveStream.receive(maximumByteCount: remaining), - !bytes.isEmpty else { - throw CmxIrohClientSessionError.unexpectedEndOfStream - } - buffer.append(bytes) - } - return ( - try admissionCodec.decodeFramePrefix(buffer), - Data(buffer.dropFirst(CmxIrohAdmissionAckCodec.frameByteCount)) - ) - } - -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionError.swift deleted file mode 100644 index f694e0b6..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionError.swift +++ /dev/null @@ -1,29 +0,0 @@ -/// Failures while establishing or operating a cmux Iroh client session. -public enum CmxIrohClientSessionError: Error, Equatable, Sendable { - /// The QUIC peer identity did not match the requested EndpointID. - case remoteIdentityMismatch - - /// The peer denied the signed admission credential. - case admissionDenied(code: UInt16) - - /// The peer closed a stream before its fixed framing completed. - case unexpectedEndOfStream - - /// A role-invalid frame appeared during the admission barrier. - case invalidAdmissionFrame - - /// An operation required an admitted control stream. - case notConnected - - /// The session was explicitly closed. - case alreadyClosed - - /// A read bound was zero or negative. - case invalidMaximumByteCount(Int) - - /// The requested outgoing lane must be terminal or artifact data. - case invalidOutgoingLane - - /// This protocol configuration has no production owner for application lanes. - case applicationLanesUnavailable -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionPool.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionPool.swift deleted file mode 100644 index 795f1b25..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionPool.swift +++ /dev/null @@ -1,578 +0,0 @@ -import CMUXMobileCore -import Foundation - -/// Retains one admitted multistream session per exact Mac peer intent. -actor CmxIrohClientSessionPool { - private struct SessionKey: Hashable, Sendable { - let runtimeGeneration: UInt64 - let identity: CmxIrohPeerIdentity - let deviceID: String - } - - private struct PendingConnection: Sendable { - let id: UUID - let task: Task<CmxIrohClientSession, any Error> - } - - private struct PooledSession: Sendable { - let id: UUID - let diagnosticID: Int - let initialPurpose: CmxTransportSessionPurpose - let session: CmxIrohClientSession - let closureTask: Task<Void, Never> - let pathObservationTask: Task<Void, Never> - } - - private struct ControlWaiter { - let id: UUID - let ownerID: UUID - let purpose: CmxTransportSessionPurpose - let continuation: CheckedContinuation<Void, Never> - } - - private struct ControlOwner { - let id: UUID - let purpose: CmxTransportSessionPurpose - } - - private let supervisor: CmxIrohEndpointSupervisor - private let contextProvider: any CmxIrohClientContextProvider - private let protocolConfiguration: CmxIrohProtocolConfiguration - private let diagnosticLog: DiagnosticLog? - private var lifecycleRevision: UInt64 = 0 - private var nextDiagnosticSessionID = 0 - private var runtimeGeneration: UInt64? - private var sessions: [SessionKey: PooledSession] = [:] - private var sessionOrder: [SessionKey] = [] - private var connectionTasks: [SessionKey: PendingConnection] = [:] - private var controlOwners: [SessionKey: ControlOwner] = [:] - private var controlWaiters: [SessionKey: [ControlWaiter]] = [:] - private var selectedPathContinuations: [UUID: AsyncStream<Void>.Continuation] = [:] - - init( - supervisor: CmxIrohEndpointSupervisor, - contextProvider: any CmxIrohClientContextProvider, - protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1, - diagnosticLog: DiagnosticLog? = nil - ) { - self.supervisor = supervisor - self.contextProvider = contextProvider - self.protocolConfiguration = protocolConfiguration - self.diagnosticLog = diagnosticLog - } - - func activate(runtimeGeneration: UInt64) async { - guard self.runtimeGeneration != runtimeGeneration else { return } - await invalidateAll(reason: .runtimeReconfigured) - self.runtimeGeneration = runtimeGeneration - } - - func deactivate() async { - await invalidateAll(reason: .runtimeDeactivated) - runtimeGeneration = nil - } - - func session( - for request: CmxByteTransportRequest, - preservesControlOwnerOnClosed: Bool = false - ) async throws -> CmxIrohClientSession { - let key = try sessionKey(for: request) - while let pooled = sessions[key] { - let isClosed = await pooled.session.isClosed() - guard sessions[key]?.id == pooled.id else { continue } - if !isClosed { - return pooled.session - } - await invalidateSession( - for: key, - matching: pooled.id, - releasesControlOwner: !preservesControlOwnerOnClosed, - reason: .closedSessionEvicted, - failure: .connectionClosed - ) - } - - let revision = lifecycleRevision - let pending: PendingConnection - if let existing = connectionTasks[key] { - pending = existing - } else { - let supervisor = supervisor - let contextProvider = contextProvider - let protocolConfiguration = protocolConfiguration - let task = Task { - let endpoint = try await supervisor.activeEndpoint() - let context = try await contextProvider.context(for: request) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: key.identity, - dialPlan: context.dialPlan, - credential: context.credential, - privateFallbackAuthorization: context.privateFallbackAuthorization, - privateFallbackValidator: contextProvider, - privateFallbackContextProvider: { - try await contextProvider.contextWithPrivateFallback( - for: request, - basedOn: context - ) - }, - protocolConfiguration: protocolConfiguration - ) - do { - try await session.connect() - try Task.checkCancellation() - return session - } catch { - await session.close() - throw error - } - } - pending = PendingConnection(id: UUID(), task: task) - connectionTasks[key] = pending - } - - do { - let connected = try await pending.task.value - guard lifecycleRevision == revision else { - await connected.close() - throw CancellationError() - } - if connectionTasks[key]?.id == pending.id { - connectionTasks[key] = nil - } - if let installed = sessions[key] { - if installed.session !== connected { - await connected.close() - } - return installed.session - } - let sessionID = UUID() - let diagnosticID = makeDiagnosticSessionID() - let closureTask = Task { [weak self] in - await connected.waitUntilClosed() - guard !Task.isCancelled else { return } - await self?.sessionDidClose(key: key, sessionID: sessionID) - } - let pathObservationTask = Task { [weak self] in - let changes = await connected.observedSelectedPathChanges() - for await _ in changes { - guard !Task.isCancelled else { return } - await self?.publishSelectedPathChange( - key: key, - sessionID: sessionID - ) - } - } - sessions[key] = PooledSession( - id: sessionID, - diagnosticID: diagnosticID, - initialPurpose: request.sessionPurpose, - session: connected, - closureTask: closureTask, - pathObservationTask: pathObservationTask - ) - sessionOrder.removeAll { $0 == key } - sessionOrder.append(key) - recordSessionLifecycle( - .established, - sessionID: diagnosticID, - purpose: controlOwners[key]?.purpose ?? request.sessionPurpose - ) - publishSelectedPathChange() - return connected - } catch { - if connectionTasks[key]?.id == pending.id { - connectionTasks[key] = nil - } - throw error - } - } - - /// Acquires exact ownership of control-stream framing before returning the - /// pooled session. Same-peer route variants wait for the existing owner to - /// close instead of failing while an intentional reconnect is handing off. - func acquireControlSession( - for request: CmxByteTransportRequest, - ownerID: UUID - ) async throws -> CmxIrohClientSession { - let key = try sessionKey(for: request) - try await reserveControlOwner( - for: key, - ownerID: ownerID, - purpose: request.sessionPurpose - ) - do { - return try await session( - for: request, - preservesControlOwnerOnClosed: true - ) - } catch { - if controlOwners[key]?.id == ownerID { - releaseControlOwner(for: key, ownerID: ownerID) - } - throw error - } - } - - func openBidirectionalLane( - for request: CmxByteTransportRequest, - lane: CmxIrohLane, - priority: Int32 - ) async throws -> CmxIrohBidirectionalStream { - let key = try sessionKey(for: request) - let session = try await session(for: request) - do { - return try await session.openBidirectionalLane(lane, priority: priority) - } catch { - try Task.checkCancellation() - guard await session.isClosed() else { throw error } - await invalidateSession( - for: key, - matching: session, - reason: .applicationLaneFailed, - failure: DiagnosticFailureKind.classify(error) - ) - let replacement = try await self.session(for: request) - return try await replacement.openBidirectionalLane( - lane, - priority: priority - ) - } - } - - func serverEventByteStream( - for request: CmxByteTransportRequest - ) async throws -> CmxIndependentEventByteStream { - let session = try await session(for: request) - return try await session.serverEventByteStream() - } - - /// Releases an exact control owner and closes its session so partial RPC - /// framing can never be inherited by a replacement owner. - func releaseControlSession( - for request: CmxByteTransportRequest, - ownerID: UUID, - reason: DiagnosticSessionLifecycleKind = .controlOwnerReleased, - failure: DiagnosticFailureKind = .none - ) async { - guard let key = try? sessionKey(for: request), - controlOwners[key]?.id == ownerID else { - return - } - await invalidateSession( - for: key, - releasesControlOwner: false, - reason: reason, - failure: failure - ) - releaseControlOwner(for: key, ownerID: ownerID) - } - - func invalidate(for request: CmxByteTransportRequest) async { - guard let key = try? sessionKey(for: request) else { return } - await invalidateSession( - for: key, - reason: .explicitlyInvalidated, - failure: .none - ) - } - - func invalidateAll() async { - await invalidateAll(reason: .runtimeDeactivated) - } - - private func invalidateAll(reason: DiagnosticSessionLifecycleKind) async { - lifecycleRevision &+= 1 - let tasks = connectionTasks.values.map(\.task) - connectionTasks.removeAll(keepingCapacity: false) - for task in tasks { task.cancel() } - let closing = sessions - let closingOwners = controlOwners - sessions.removeAll(keepingCapacity: false) - sessionOrder.removeAll(keepingCapacity: false) - controlOwners.removeAll(keepingCapacity: false) - let waiters = controlWaiters.values.flatMap { $0 } - controlWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.continuation.resume() } - for (key, pooled) in closing { - pooled.closureTask.cancel() - pooled.pathObservationTask.cancel() - recordSessionClosure( - reason, - pooled: pooled, - purpose: closingOwners[key]?.purpose ?? pooled.initialPurpose, - failure: .none - ) - await pooled.session.close() - } - publishSelectedPathChange() - } - - func selectedObservedPath() async -> CmxIrohObservedConnectionPath { - let foregroundKey = sessionOrder.last { key in - controlOwners[key]?.purpose == .foregroundControl - && sessions[key] != nil - } - let controlKey = sessionOrder.last { key in - controlOwners[key] != nil && sessions[key] != nil - } - guard let key = foregroundKey ?? controlKey ?? sessionOrder.last, - let session = sessions[key]?.session else { return .unavailable } - return await session.observedSelectedPath() - } - - func selectedPathChanges() -> AsyncStream<Void> { - let id = UUID() - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - selectedPathContinuations[id] = continuation - continuation.yield(()) - continuation.onTermination = { @Sendable [weak self] _ in - Task { await self?.removeSelectedPathContinuation(id: id) } - } - } - } - - func controlWaiterCount(for request: CmxByteTransportRequest) -> Int { - guard let key = try? sessionKey(for: request) else { return 0 } - return controlWaiters[key]?.count ?? 0 - } - - private func sessionDidClose(key: SessionKey, sessionID: UUID) async { - guard let pooled = sessions[key], pooled.id == sessionID else { return } - let owner = controlOwners[key] - sessions[key] = nil - sessionOrder.removeAll { $0 == key } - pooled.pathObservationTask.cancel() - recordSessionClosure( - .remoteClosed, - pooled: pooled, - purpose: owner?.purpose ?? pooled.initialPurpose, - failure: .connectionClosed - ) - await pooled.session.close() - if let owner { - releaseControlOwner(for: key, ownerID: owner.id) - } - publishSelectedPathChange() - } - - private func invalidateSession( - for key: SessionKey, - releasesControlOwner: Bool = true, - reason: DiagnosticSessionLifecycleKind, - failure: DiagnosticFailureKind - ) async { - await invalidateSession( - for: key, - matching: Optional<UUID>.none, - releasesControlOwner: releasesControlOwner, - reason: reason, - failure: failure - ) - } - - private func invalidateSession( - for key: SessionKey, - matching expectedID: UUID?, - releasesControlOwner: Bool = true, - reason: DiagnosticSessionLifecycleKind, - failure: DiagnosticFailureKind - ) async { - if let expectedID, sessions[key]?.id != expectedID { return } - let currentOwner = controlOwners[key] - let owner = releasesControlOwner ? currentOwner : nil - connectionTasks[key]?.task.cancel() - connectionTasks[key] = nil - let pooled = sessions.removeValue(forKey: key) - sessionOrder.removeAll { $0 == key } - pooled?.closureTask.cancel() - pooled?.pathObservationTask.cancel() - if let pooled { - recordSessionClosure( - reason, - pooled: pooled, - purpose: currentOwner?.purpose ?? pooled.initialPurpose, - failure: failure - ) - } - await pooled?.session.close() - if let owner { - releaseControlOwner(for: key, ownerID: owner.id) - } - publishSelectedPathChange() - } - - private func invalidateSession( - for key: SessionKey, - matching expectedSession: CmxIrohClientSession, - reason: DiagnosticSessionLifecycleKind, - failure: DiagnosticFailureKind - ) async { - guard let pooled = sessions[key], pooled.session === expectedSession else { return } - await invalidateSession( - for: key, - matching: pooled.id, - reason: reason, - failure: failure - ) - } - - private func reserveControlOwner( - for key: SessionKey, - ownerID: UUID, - purpose: CmxTransportSessionPurpose - ) async throws { - if let existing = controlOwners[key] { - if existing.id == ownerID { return } - } else { - controlOwners[key] = ControlOwner(id: ownerID, purpose: purpose) - publishSelectedPathChangeIfEstablished(for: key) - return - } - - let waiterID = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - guard !Task.isCancelled else { - continuation.resume() - return - } - if let existing = controlOwners[key] { - if existing.id == ownerID { - continuation.resume() - } else { - controlWaiters[key, default: []].append(ControlWaiter( - id: waiterID, - ownerID: ownerID, - purpose: purpose, - continuation: continuation - )) - } - } else { - controlOwners[key] = ControlOwner(id: ownerID, purpose: purpose) - publishSelectedPathChangeIfEstablished(for: key) - continuation.resume() - } - } - } onCancel: { - Task { await self.cancelControlWaiter(for: key, id: waiterID) } - } - - do { - try Task.checkCancellation() - guard controlOwners[key]?.id == ownerID else { - throw CmxIrohClientRuntimeError.inactive - } - } catch { - cancelControlWaiter(for: key, id: waiterID) - if controlOwners[key]?.id == ownerID { - releaseControlOwner(for: key, ownerID: ownerID) - } - throw error - } - } - - private func cancelControlWaiter(for key: SessionKey, id: UUID) { - guard var waiters = controlWaiters[key], - let index = waiters.firstIndex(where: { $0.id == id }) else { - return - } - let waiter = waiters.remove(at: index) - controlWaiters[key] = waiters.isEmpty ? nil : waiters - waiter.continuation.resume() - } - - private func releaseControlOwner(for key: SessionKey, ownerID: UUID) { - guard controlOwners[key]?.id == ownerID else { return } - controlOwners[key] = nil - guard var waiters = controlWaiters[key], !waiters.isEmpty else { - publishSelectedPathChangeIfEstablished(for: key) - return - } - let next = waiters.removeFirst() - controlWaiters[key] = waiters.isEmpty ? nil : waiters - controlOwners[key] = ControlOwner(id: next.ownerID, purpose: next.purpose) - publishSelectedPathChangeIfEstablished(for: key) - next.continuation.resume() - } - - private func makeDiagnosticSessionID() -> Int { - if nextDiagnosticSessionID == Int.max { - nextDiagnosticSessionID = 1 - } else { - nextDiagnosticSessionID += 1 - } - return nextDiagnosticSessionID - } - - private func recordSessionLifecycle( - _ kind: DiagnosticSessionLifecycleKind, - sessionID: Int, - purpose: CmxTransportSessionPurpose - ) { - diagnosticLog?.record(DiagnosticEvent( - .transportSessionLifecycle, - a: kind.rawValue, - b: Int(purpose.rawValue), - c: sessionID - )) - } - - private func recordSessionClosure( - _ kind: DiagnosticSessionLifecycleKind, - pooled: PooledSession, - purpose: CmxTransportSessionPurpose, - failure: DiagnosticFailureKind - ) { - recordSessionLifecycle( - kind, - sessionID: pooled.diagnosticID, - purpose: purpose - ) - diagnosticLog?.record(DiagnosticEvent( - .sessionClosed, - a: DiagnosticTransportKind.iroh.rawValue, - b: failure.rawValue, - c: pooled.diagnosticID - )) - } - - private func publishSelectedPathChangeIfEstablished(for key: SessionKey) { - guard sessions[key] != nil else { return } - publishSelectedPathChange() - } - - private func publishSelectedPathChange() { - for continuation in selectedPathContinuations.values { - continuation.yield(()) - } - } - - private func publishSelectedPathChange(key: SessionKey, sessionID: UUID) { - guard sessions[key]?.id == sessionID else { return } - publishSelectedPathChange() - } - - private func removeSelectedPathContinuation(id: UUID) { - selectedPathContinuations[id] = nil - } - - private func sessionKey(for request: CmxByteTransportRequest) throws -> SessionKey { - try request.route.validate() - guard let runtimeGeneration else { - throw CmxIrohClientRuntimeError.inactive - } - guard request.route.kind == .iroh, - request.authorizationMode == .transportAdmission, - let deviceID = request.expectedPeerDeviceID, - let canonicalDeviceID = CmxIrohDeviceID(deviceID)?.value, - case let .peer(identity, _) = request.route.endpoint else { - throw CmxIrohByteTransportError.missingPeerIntent - } - return SessionKey( - runtimeGeneration: runtimeGeneration, - identity: identity, - deviceID: canonicalDeviceID - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSignOutPreparation.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSignOutPreparation.swift deleted file mode 100644 index dc4e38a0..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSignOutPreparation.swift +++ /dev/null @@ -1,54 +0,0 @@ -/// The non-secret binding captured before local sign-out state is destroyed. -public struct CmxIrohSignOutPreparation: Equatable, Sendable { - /// The account-and-tag-scoped binding queued before local teardown. - public let pendingRevocation: CmxIrohPendingRevocation? - - /// Whether the first device-only persistence attempt succeeded. - public let wasPersisted: Bool - - /// The broker binding to revoke, or `nil` before registration. - public var bindingID: String? { pendingRevocation?.bindingID } - - /// Creates a sign-out handoff after local endpoint and credential teardown. - /// - /// - Parameters: - /// - pendingRevocation: The validated prior binding, or `nil` before registration. - /// - wasPersisted: Whether it was durably queued before local teardown. - public init( - pendingRevocation: CmxIrohPendingRevocation?, - wasPersisted: Bool - ) { - self.pendingRevocation = pendingRevocation - self.wasPersisted = pendingRevocation == nil || wasPersisted - } - - /// Revokes the captured binding with a broker authenticated from captured tokens. - /// - /// A missing binding is a successful no-op. If initial persistence failed, - /// this method retries the durable enqueue before contacting the broker. - /// - /// - Parameter broker: A broker client whose token source holds the - /// access and refresh tokens captured before auth's local teardown. - /// - Parameter pendingRevocations: The same device-only outbox used by the runtime. - /// - Throws: The broker revocation error for an existing binding. - public func revoke( - using broker: any CmxIrohClientBrokerServing, - pendingRevocations: CmxIrohPendingRevocationOutbox - ) async throws { - guard let pendingRevocation else { return } - if !wasPersisted { - try await pendingRevocations.enqueue(pendingRevocation) - } - try await pendingRevocations.revokePending( - accountID: pendingRevocation.accountID, - beforeRegisteringTag: pendingRevocation.tag, - using: broker - ) - } -} - -/// The iOS name for the shared sign-out handoff. -public typealias CmxIrohClientSignOutPreparation = CmxIrohSignOutPreparation - -/// The macOS name for the shared sign-out handoff. -public typealias CmxIrohHostSignOutPreparation = CmxIrohSignOutPreparation diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectedControl.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectedControl.swift deleted file mode 100644 index 7a3d7f17..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectedControl.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -/// The admitted connection and control stream produced by one connect task. -struct CmxIrohConnectedControl: Sendable { - let connection: any CmxIrohConnection - let stream: CmxIrohBidirectionalStream - let initialReceiveBuffer: Data -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnection.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnection.swift deleted file mode 100644 index 2805a32a..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnection.swift +++ /dev/null @@ -1,65 +0,0 @@ -public import CMUXMobileCore - -/// An authenticated Iroh QUIC connection capable of independent app streams. -public protocol CmxIrohConnection: Sendable { - /// Returns the peer EndpointID authenticated by QUIC TLS. - func remoteIdentity() async -> CmxIrohPeerIdentity - - /// Bounds streams initiated by the peer before application code accepts them. - /// - /// A zero limit disables that stream direction. The limit applies to - /// concurrent streams and QUIC releases capacity when a stream closes. - func setIncomingStreamLimits( - maximumBidirectionalStreamCount: UInt64, - maximumUnidirectionalStreamCount: UInt64 - ) async throws - - /// Irreversibly enables NAT-traversal candidate exchange on this connection. - /// - /// Calls are idempotent. Admission code invokes this only after authenticating - /// the peer and keeps application lanes unavailable until both peers confirm it. - func authorizeNatTraversal() async throws - - /// Opens a new bidirectional application stream. - /// - /// - Returns: Independent receive and send halves. - /// - Throws: A transport error or `CancellationError`. - func openBidirectionalStream() async throws -> CmxIrohBidirectionalStream - - /// Accepts the next peer-created bidirectional stream. - /// - /// - Returns: Independent receive and send halves. - /// - Throws: A transport error or `CancellationError`. - func acceptBidirectionalStream() async throws -> CmxIrohBidirectionalStream - - /// Opens a new unidirectional send stream. - /// - /// - Returns: The writable stream half. - /// - Throws: A transport error or `CancellationError`. - func openSendStream() async throws -> any CmxIrohSendStream - - /// Accepts the next peer-created unidirectional receive stream. - /// - /// - Returns: The readable stream half. - /// - Throws: A transport error or `CancellationError`. - func acceptReceiveStream() async throws -> any CmxIrohReceiveStream - - /// Suspends until this exact QUIC connection has closed. - /// - /// Session-scoped policy monitors use this signal to retain revocation - /// enforcement for the complete connection lifetime. - func waitUntilClosed() async - - /// Returns whether this connection already has a terminal close reason. - /// - /// Callers use this nonblocking snapshot to reject a cached connection - /// before its asynchronous closure watcher has been scheduled. - func isClosed() async -> Bool - - /// Closes the complete connection and all child streams. - /// - /// - Parameters: - /// - errorCode: The application close code. - /// - reason: A bounded non-sensitive reason for local diagnostics. - func close(errorCode: UInt64, reason: String) async -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectionPathInspecting.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectionPathInspecting.swift deleted file mode 100644 index 5730368e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectionPathInspecting.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Internal capability for reading a redaction-boundary path snapshot. -protocol CmxIrohConnectionPathInspecting: Sendable { - func observedSelectedPath() async -> CmxIrohObservedConnectionPath - func observedSelectedPathChanges() async -> AsyncStream<CmxIrohObservedConnectionPath> -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectionPathSnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectionPathSnapshot.swift deleted file mode 100644 index 86afa41d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohConnectionPathSnapshot.swift +++ /dev/null @@ -1,23 +0,0 @@ -import IrohLib - -/// The minimum package-internal data copied from one Iroh FFI path snapshot. -struct CmxIrohConnectionPathSnapshot: Equatable, Sendable { - let isSelected: Bool - let remoteAddress: String - let isIP: Bool - let isRelay: Bool - - init(_ snapshot: PathSnapshot) { - isSelected = snapshot.isSelected - remoteAddress = snapshot.remoteAddr - isIP = snapshot.isIp - isRelay = snapshot.isRelay - } - - init(isSelected: Bool, remoteAddress: String, isIP: Bool, isRelay: Bool) { - self.isSelected = isSelected - self.remoteAddress = remoteAddress - self.isIP = isIP - self.isRelay = isRelay - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift deleted file mode 100644 index 5b5d7ded..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomPrivatePathStore.swift +++ /dev/null @@ -1,317 +0,0 @@ -public import CMUXMobileCore -import CryptoKit -import Foundation - -/// Validated device-local settings for one authenticated Mac. -public struct CmxIrohCustomPrivatePathConfiguration: Codable, Equatable, Sendable { - public let macDeviceID: String - public let macDisplayName: String - public let addresses: [CmxIrohCustomPrivateAddress] - public let isEnabled: Bool - - public init( - macDeviceID: String, - macDisplayName: String, - addresses: [CmxIrohCustomPrivateAddress], - isEnabled: Bool - ) throws { - let canonicalDeviceID = cmxCanonicalDeviceID( - macDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) - ) - guard let uuid = UUID(uuidString: canonicalDeviceID), - uuid.uuidString.lowercased() == canonicalDeviceID, - !addresses.isEmpty, - addresses.count <= CmxIrohCustomPrivatePathDraft.maximumAddressCount, - Set(addresses).count == addresses.count else { - throw CmxIrohCustomPrivatePathStoreError.invalidConfiguration - } - let displayName = macDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) - guard displayName.utf8.count <= 128, - !displayName.unicodeScalars.contains(where: { - $0.value < 0x20 || $0.value == 0x7f - }) else { - throw CmxIrohCustomPrivatePathStoreError.invalidConfiguration - } - self.macDeviceID = canonicalDeviceID - self.macDisplayName = displayName - self.addresses = addresses - self.isEnabled = isEnabled - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - macDeviceID: container.decode(String.self, forKey: .macDeviceID), - macDisplayName: container.decode(String.self, forKey: .macDisplayName), - addresses: container.decode( - [CmxIrohCustomPrivateAddress].self, - forKey: .addresses - ), - isEnabled: container.decode(Bool.self, forKey: .isEnabled) - ) - } -} - -/// Credential-free state used to compose private-path authorization. -public struct CmxIrohCustomPrivatePathSnapshot: Equatable, Sendable { - public let generation: UInt64 - public let configurations: [CmxIrohCustomPrivatePathConfiguration] - public let activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> - - public init( - generation: UInt64, - configurations: [CmxIrohCustomPrivatePathConfiguration], - activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> - ) { - self.generation = generation - self.configurations = configurations - self.activeNetworkProfiles = activeNetworkProfiles - } - - public static let unavailable = CmxIrohCustomPrivatePathSnapshot( - generation: 1, - configurations: [], - activeNetworkProfiles: [] - ) -} - -/// One locally configured address plus its opaque active-profile authority. -public struct CmxIrohCustomPrivatePathBootstrap: Equatable, Sendable { - public let address: CmxIrohCustomPrivateAddress - public let networkProfile: CmxIrohNetworkProfileKey - - public init( - address: CmxIrohCustomPrivateAddress, - networkProfile: CmxIrohNetworkProfileKey - ) throws { - guard networkProfile.source == .customVPN else { - throw CmxIrohCustomPrivatePathStoreError.invalidConfiguration - } - self.address = address - self.networkProfile = networkProfile - } -} - -/// Device-only, account-isolated persistence for explicit private addresses. -/// -/// Addresses are non-secret routing preferences, so this store deliberately -/// uses app-local defaults instead of Keychain. This keeps the transport's -/// Keychain-stall degradation unchanged while preventing account or broker sync. -public actor CmxIrohCustomPrivatePathStore { - struct Record: Codable { - let version: Int - let generation: UInt64 - let configurations: [CmxIrohCustomPrivatePathConfiguration] - } - - struct State { - var generation: UInt64 - var configurations: [CmxIrohCustomPrivatePathConfiguration] - } - - public static let maximumConfigurationCount = 64 - private static let recordVersion = 1 - private static let maximumEncodedByteCount = 64 * 1_024 - - private let store: any CmxIrohInstallStateStoring - private var states: [String: State] = [:] - - public init( - store: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore() - ) { - self.store = store - } - - public func snapshot(accountID: String) throws -> CmxIrohCustomPrivatePathSnapshot { - let scope = try storageScope(accountID) - let state = try state(for: scope) - return try snapshot(state: state, scope: scope) - } - - /// Loads current preferences, failing closed without disabling Iroh when - /// local settings are malformed or unavailable. - public func availableSnapshot( - accountID: String - ) -> CmxIrohCustomPrivatePathSnapshot { - (try? snapshot(accountID: accountID)) ?? .unavailable - } - - public func upsert( - _ draft: CmxIrohCustomPrivatePathDraft, - accountID: String - ) throws -> CmxIrohCustomPrivatePathSnapshot { - let configuration = try Self.validatedConfiguration(draft) - let scope = try storageScope(accountID) - var state = try state(for: scope) - if let index = state.configurations.firstIndex(where: { - $0.macDeviceID == configuration.macDeviceID - }) { - state.configurations[index] = configuration - } else { - guard state.configurations.count < Self.maximumConfigurationCount else { - throw CmxIrohCustomPrivatePathStoreError.tooManyConfigurations - } - state.configurations.append(configuration) - } - state.configurations.sort { $0.macDeviceID < $1.macDeviceID } - state.generation = nextGeneration(state.generation) - try persist(state, scope: scope) - states[scope] = state - return try snapshot(state: state, scope: scope) - } - - public func remove( - macDeviceID: String, - accountID: String - ) throws -> CmxIrohCustomPrivatePathSnapshot { - let canonical = cmxCanonicalDeviceID( - macDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) - ) - let scope = try storageScope(accountID) - var state = try state(for: scope) - guard state.configurations.contains(where: { $0.macDeviceID == canonical }) else { - throw CmxIrohCustomPrivatePathStoreError.missingConfiguration - } - state.configurations.removeAll { $0.macDeviceID == canonical } - state.generation = nextGeneration(state.generation) - try persist(state, scope: scope) - states[scope] = state - return try snapshot(state: state, scope: scope) - } - - /// Returns paths only for the exact expected Mac and current account. - public func enabledPaths( - forMacDeviceID macDeviceID: String, - accountID: String - ) -> [CmxIrohCustomPrivatePathBootstrap] { - guard let scope = try? storageScope(accountID), - let state = try? state(for: scope), - let configuration = state.configurations.first(where: { - $0.macDeviceID == cmxCanonicalDeviceID(macDeviceID) - }), - configuration.isEnabled, - let profile = try? profile( - accountScope: scope, - macDeviceID: configuration.macDeviceID - ) else { return [] } - return configuration.addresses.compactMap { - try? CmxIrohCustomPrivatePathBootstrap( - address: $0, - networkProfile: profile - ) - } - } - - private static func validatedConfiguration( - _ draft: CmxIrohCustomPrivatePathDraft - ) throws -> CmxIrohCustomPrivatePathConfiguration { - var addresses: [CmxIrohCustomPrivateAddress] = [] - for value in draft.addresses { - let address = try CmxIrohCustomPrivateAddress(value) - if !addresses.contains(address) { addresses.append(address) } - } - return try CmxIrohCustomPrivatePathConfiguration( - macDeviceID: draft.macDeviceID, - macDisplayName: draft.macDisplayName, - addresses: addresses, - isEnabled: draft.isEnabled - ) - } - - private func storageScope(_ accountID: String) throws -> String { - try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-private-paths" - ) - } - - private func state(for scope: String) throws -> State { - if let state = states[scope] { return state } - let loaded: State - if let encoded = store.string(forKey: scope) { - guard let data = Data(base64Encoded: encoded), - data.count <= Self.maximumEncodedByteCount, - let record = try? JSONDecoder().decode(Record.self, from: data), - record.version == Self.recordVersion, - record.generation > 0, - record.configurations.count <= Self.maximumConfigurationCount, - Set(record.configurations.map(\.macDeviceID)).count - == record.configurations.count else { - throw CmxIrohCustomPrivatePathStoreError.invalidStoredConfiguration - } - loaded = State( - generation: record.generation, - configurations: record.configurations.sorted { - $0.macDeviceID < $1.macDeviceID - } - ) - } else { - loaded = State(generation: 1, configurations: []) - } - states[scope] = loaded - return loaded - } - - private func persist(_ state: State, scope: String) throws { - let data = try JSONEncoder().encode(Record( - version: Self.recordVersion, - generation: state.generation, - configurations: state.configurations - )) - guard data.count <= Self.maximumEncodedByteCount else { - throw CmxIrohCustomPrivatePathStoreError.invalidConfiguration - } - store.set(data.base64EncodedString(), forKey: scope) - } - - private func snapshot( - state: State, - scope: String - ) throws -> CmxIrohCustomPrivatePathSnapshot { - var profiles: Set<CmxIrohNetworkProfileKey> = [] - for configuration in state.configurations - where configuration.isEnabled && !configuration.addresses.isEmpty { - profiles.insert(try profile( - accountScope: scope, - macDeviceID: configuration.macDeviceID - )) - } - return CmxIrohCustomPrivatePathSnapshot( - generation: state.generation, - configurations: state.configurations, - activeNetworkProfiles: profiles - ) - } - - private func profile( - accountScope: String, - macDeviceID: String - ) throws -> CmxIrohNetworkProfileKey { - let digest = SHA256.hash( - data: Data("custom-private-path-v1\0\(accountScope)\0\(macDeviceID)".utf8) - ) - var encoded = [UInt8]() - encoded.reserveCapacity(SHA256.Digest.byteCount * 2) - for byte in digest { - encoded.append(Self.hexDigits[Int(byte >> 4)]) - encoded.append(Self.hexDigits[Int(byte & 0x0f)]) - } - return try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: String(decoding: encoded, as: UTF8.self) - ) - } - - private func nextGeneration(_ current: UInt64) -> UInt64 { - current == .max ? 1 : current + 1 - } - - private static let hexDigits = Array("0123456789abcdef".utf8) -} - -public enum CmxIrohCustomPrivatePathStoreError: Error, Equatable, Sendable { - case invalidConfiguration - case invalidStoredConfiguration - case tooManyConfigurations - case missingConfiguration -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelay.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelay.swift deleted file mode 100644 index 0b108755..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelay.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -/// One user-controlled Iroh relay and its optional static authentication token. -public struct CmxIrohCustomRelay: Equatable, Sendable { - /// Canonical HTTPS relay origin, including an optional explicit port. - public let url: String - - /// Optional relay authentication token, which must be persisted in secure storage. - public let authenticationToken: String? - - /// Creates a validated custom relay. - /// - /// - Parameters: - /// - url: Canonical HTTPS origin ending in `/`; an explicit port is allowed. - /// - authenticationToken: Optional provider-defined static token. - /// - Throws: ``CmxIrohRelayPolicyError/invalidClaims`` for unsafe input. - public init(url: String, authenticationToken: String? = nil) throws { - guard Self.isCanonicalURL(url), - authenticationToken.map(Self.isSafeToken) ?? true else { - throw CmxIrohRelayPolicyError.invalidClaims - } - self.url = url - self.authenticationToken = authenticationToken - } - - private static func isCanonicalURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } - - private static func isSafeToken(_ value: String) -> Bool { - (1 ... 8 * 1_024).contains(value.utf8.count) - && !value.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7f } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayAuthMode.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayAuthMode.swift deleted file mode 100644 index 22979a51..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayAuthMode.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// Authentication required by a user-defined relay. -public enum CmxIrohCustomRelayAuthMode: String, Codable, Equatable, Sendable { - /// The relay accepts unauthenticated clients. - case none - - /// The device must supply a user-provided static token from secure storage. - case staticToken = "device_secret" -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayCredentialStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayCredentialStore.swift deleted file mode 100644 index 5e208bb8..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayCredentialStore.swift +++ /dev/null @@ -1,213 +0,0 @@ -public import Foundation - -/// Device-local secure storage for user-provided custom relay tokens. -public actor CmxIrohCustomRelayCredentialStore { - private struct StaticCredential: Codable, Equatable { - let token: String - let relayURL: String - } - - private struct Record: Codable { - let version: Int - var staticCredentials: [String: StaticCredential] - } - - private struct LegacyRecord: Codable { - let version: Int - let staticTokens: [String: String] - } - - private static let recordVersion = 2 - private let secureStore: any CmxIrohSecureCredentialStoring - private var busyAccounts: Set<String> = [] - private var accountWaiters: [String: [CheckedContinuation<Void, Never>]] = [:] - - /// Creates an account-scoped custom relay credential repository. - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( - service: "com.cmuxterm.iroh.custom-relay-credentials.v1" - ) - ) { - self.secureStore = secureStore - } - - /// Saves or replaces one static relay token for the authenticated account. - public func setStaticToken( - _ token: String, - relayID: String, - relayURL: String, - accountID: String - ) async throws { - guard CmxIrohRelayStorageScope.isSafeRelayID(relayID), - CmxIrohRelayStorageScope.isSafeToken(token), - (try? CmxIrohCustomRelay(url: relayURL)) != nil else { - throw CmxIrohRelayPolicyError.invalidClaims - } - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-relay-credentials" - ) - await acquire(account) - defer { release(account) } - var credentials = try await storedCredentials(account: account) - credentials[relayID] = StaticCredential(token: token, relayURL: relayURL) - try await write(credentials, account: account) - } - - /// Removes one device-local relay token without changing the account preference. - public func removeCredential(relayID: String, accountID: String) async throws { - guard CmxIrohRelayStorageScope.isSafeRelayID(relayID) else { - throw CmxIrohRelayPolicyError.invalidClaims - } - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-relay-credentials" - ) - await acquire(account) - defer { release(account) } - var credentials = try await storedCredentials(account: account) - credentials.removeValue(forKey: relayID) - if credentials.isEmpty { - try await secureStore.delete(account: account) - } else { - try await write(credentials, account: account) - } - } - - /// Removes every custom relay token for one authenticated account. - public func deactivate(accountID: String) async throws { - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-relay-credentials" - ) - await acquire(account) - defer { release(account) } - try await secureStore.delete(account: account) - } - - /// Deletes tokens that no longer correspond to saved secret-bearing relays. - /// Calling this after every authoritative account update also retries cleanup - /// after a previous transient Keychain failure. - public func retainCredentials( - for relays: [CmxIrohCustomRelayDefinition], - accountID: String - ) async throws { - guard relays.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount, - Set(relays.map(\.id)).count == relays.count else { - throw CmxIrohRelayPolicyError.invalidSelection - } - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-relay-credentials" - ) - await acquire(account) - defer { release(account) } - let credentials = try await storedCredentials(account: account) - let definitions = Dictionary(uniqueKeysWithValues: relays.map { ($0.id, $0) }) - let retained = credentials.filter { id, credential in - guard let relay = definitions[id] else { return false } - return relay.authMode == .staticToken && relay.url == credential.relayURL - } - guard retained != credentials else { return } - if retained.isEmpty { - try await secureStore.delete(account: account) - } else { - try await write(retained, account: account) - } - } - - func staticTokens( - for relays: [CmxIrohCustomRelayDefinition], - accountID: String - ) async throws -> [String: String] { - guard relays.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount, - Set(relays.map(\.id)).count == relays.count else { - throw CmxIrohRelayPolicyError.invalidSelection - } - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-relay-credentials" - ) - await acquire(account) - defer { release(account) } - let credentials = try await storedCredentials(account: account) - var tokens: [String: String] = [:] - for relay in relays where relay.authMode == .staticToken { - guard let credential = credentials[relay.id], - credential.relayURL == relay.url else { continue } - tokens[relay.id] = credential.token - } - return tokens - } - - func configuredRelayIDs(accountID: String) async throws -> Set<String> { - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "custom-relay-credentials" - ) - await acquire(account) - defer { release(account) } - return Set(try await storedCredentials(account: account).keys) - } - - private func acquire(_ account: String) async { - guard busyAccounts.contains(account) else { - busyAccounts.insert(account) - return - } - await withCheckedContinuation { continuation in - accountWaiters[account, default: []].append(continuation) - } - } - - private func release(_ account: String) { - guard var waiters = accountWaiters[account], !waiters.isEmpty else { - busyAccounts.remove(account) - accountWaiters.removeValue(forKey: account) - return - } - let next = waiters.removeFirst() - if waiters.isEmpty { - accountWaiters.removeValue(forKey: account) - } else { - accountWaiters[account] = waiters - } - next.resume() - } - - private func storedCredentials(account: String) async throws -> [String: StaticCredential] { - guard let data = try await secureStore.read(account: account) else { return [:] } - if let record = try? JSONDecoder().decode(Record.self, from: data), - record.version == Self.recordVersion, - record.staticCredentials.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount, - record.staticCredentials.allSatisfy({ id, credential in - CmxIrohRelayStorageScope.isSafeRelayID(id) - && CmxIrohRelayStorageScope.isSafeToken(credential.token) - && (try? CmxIrohCustomRelay(url: credential.relayURL)) != nil - }) { - return record.staticCredentials - } - if let legacy = try? JSONDecoder().decode(LegacyRecord.self, from: data), - legacy.version == 1 { - try await secureStore.delete(account: account) - return [:] - } - throw CmxIrohRelayPolicyError.invalidClaims - } - - private func write( - _ credentials: [String: StaticCredential], - account: String - ) async throws { - guard credentials.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohRelayPolicyError.invalidSelection - } - try await secureStore.write( - JSONEncoder().encode( - Record(version: Self.recordVersion, staticCredentials: credentials) - ), - account: account, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayDefinition.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayDefinition.swift deleted file mode 100644 index bed67bf5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayDefinition.swift +++ /dev/null @@ -1,118 +0,0 @@ -public import Foundation - -/// A non-secret custom relay definition synchronized through the account broker. -public struct CmxIrohCustomRelayDefinition: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case id - case url - case provider - case region - case displayName - case authMode - } - - /// Stable account-scoped identifier used to find device-local credentials. - public let id: String - - /// Canonical HTTPS relay origin, including an optional explicit port. - public let url: String - - /// User-defined provider label used only for selection and diagnostics. - public let provider: String - - /// User-defined region label used only for selection and diagnostics. - public let region: String - - /// Optional human-readable relay name. - public let displayName: String? - - /// Authentication required by the relay. - public let authMode: CmxIrohCustomRelayAuthMode - - /// Creates a validated, non-secret relay definition. - public init( - id: String, - url: String, - provider: String, - region: String, - displayName: String? = nil, - authMode: CmxIrohCustomRelayAuthMode - ) throws { - guard Self.isSafeIdentifier(id), - Self.isCanonicalURL(url), - Self.isSafeLabel(provider), - Self.isSafeLabel(region), - displayName.map(Self.isSafeDisplayName) ?? true else { - throw CmxIrohRelayPolicyError.invalidClaims - } - self.id = id - self.url = url - self.provider = provider - self.region = region - self.displayName = displayName - self.authMode = authMode - } - - /// Decodes and revalidates one broker-supplied definition. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - do { - try self.init( - id: container.decode(String.self, forKey: .id), - url: container.decode(String.self, forKey: .url), - provider: container.decode(String.self, forKey: .provider), - region: container.decode(String.self, forKey: .region), - displayName: container.decodeIfPresent(String.self, forKey: .displayName), - authMode: container.decode(CmxIrohCustomRelayAuthMode.self, forKey: .authMode) - ) - } catch { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid custom relay") - ) - } - } - - private static func isSafeIdentifier(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 95].contains(byte) - } - } - - private static func isSafeDisplayName(_ value: String) -> Bool { - !value.isEmpty - && value.utf16.count <= 128 - && !value.unicodeScalars.contains { $0.value <= 0x1f || $0.value == 0x7f } - } - - private static func isSafeLabel(_ value: String) -> Bool { - guard (1 ... 80).contains(value.utf8.count), - value.utf8.first != 32, - value.utf8.last != 32 else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [32, 45, 46, 95].contains(byte) - } - } - - private static func isCanonicalURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProbe.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProbe.swift deleted file mode 100644 index f3208b25..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProbe.swift +++ /dev/null @@ -1,117 +0,0 @@ -public import Foundation - -/// Tests a custom relay through a throwaway endpoint without mutating live runtime state. -public struct CmxIrohCustomRelayProbe: Sendable { - private enum Observation: Sendable { - case reachable(String) - case closed - case timedOut - } - - private let factory: any CmxIrohEndpointFactory - private let randomness: any CmxIrohRandomByteGenerating - private let clock: any CmxIrohRelayClock - - /// Creates an isolated custom relay probe. - public init( - factory: any CmxIrohEndpointFactory = CmxIrohLibEndpointFactory(), - randomness: any CmxIrohRandomByteGenerating = CmxIrohSystemRandomByteGenerator(), - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() - ) { - self.factory = factory - self.randomness = randomness - self.clock = clock - } - - /// Binds a temporary endpoint and waits for one allowed custom relay. - /// - /// The endpoint uses a fresh unpersisted key, grants no stream credit, and - /// is always closed before this method returns. - public func probe( - profile: CmxIrohEndpointRelayProfile, - timeout: TimeInterval = 10 - ) async -> CmxIrohCustomRelayProbeResult { - guard profile.source == .custom, - !profile.allowedRelayURLs.isEmpty, - (0.1 ... 30).contains(timeout), - let secret = try? CmxIrohSecretKey(bytes: randomness.randomBytes(count: 32)) else { - return .invalidProfile - } - let configuration = CmxIrohEndpointConfiguration( - secretKey: secret, - alpns: [Data("cmux/custom-relay-probe/1".utf8)], - relayProfile: profile - ) - let endpoint: any CmxIrohEndpoint - do { - endpoint = try await factory.bind(configuration: configuration) - } catch { - return .bindFailed - } - - let result = await observe( - endpoint: endpoint, - allowedRelayURLs: profile.allowedRelayURLs, - deadline: clock.now().addingTimeInterval(timeout) - ) - await endpoint.close() - switch result { - case let .reachable(relayURL): - return .reachable(relayURL: relayURL) - case .closed: - return .endpointClosed - case .timedOut: - return .timedOut - } - } - - private func observe( - endpoint: any CmxIrohEndpoint, - allowedRelayURLs: Set<String>, - deadline: Date - ) async -> Observation { - if let relayURL = await selectedRelayURL( - endpoint: endpoint, - allowedRelayURLs: allowedRelayURLs - ) { - return .reachable(relayURL) - } - return await withTaskGroup(of: Observation.self) { group in - group.addTask { - let events = await endpoint.healthEvents() - for await event in events { - guard !Task.isCancelled else { return .timedOut } - if event == .closedUnexpectedly { return .closed } - if let relayURL = await selectedRelayURL( - endpoint: endpoint, - allowedRelayURLs: allowedRelayURLs - ) { - return .reachable(relayURL) - } - } - return .closed - } - group.addTask { [clock] in - do { - try await clock.sleep(until: deadline) - } catch { - return .timedOut - } - return .timedOut - } - let first = await group.next() ?? .timedOut - group.cancelAll() - return first - } - } - - private func selectedRelayURL( - endpoint: any CmxIrohEndpoint, - allowedRelayURLs: Set<String> - ) async -> String? { - let address = await endpoint.address() - return address.pathHints.first { - $0.kind == .relayURL && allowedRelayURLs.contains($0.value) - }?.value - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProbeResult.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProbeResult.swift deleted file mode 100644 index e438c367..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProbeResult.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Redacted outcome of an isolated custom relay reachability probe. -public enum CmxIrohCustomRelayProbeResult: Equatable, Sendable { - /// The temporary Iroh endpoint selected this exact custom relay. - case reachable(relayURL: String) - - /// The profile was not a usable custom relay override. - case invalidProfile - - /// The temporary endpoint could not be created. - case bindFailed - - /// The endpoint closed before advertising an allowed custom relay. - case endpointClosed - - /// No allowed custom relay became reachable before the bounded deadline. - case timedOut -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProfile.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProfile.swift deleted file mode 100644 index fc30fdfd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProfile.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// A strict user-controlled relay override that excludes every managed provider. -public struct CmxIrohCustomRelayProfile: Equatable, Sendable { - /// The complete custom relay set used when Iroh needs relay assistance. - public let relays: [CmxIrohCustomRelay] - - /// Creates a bounded custom relay override. - /// - /// - Parameter relays: Between one and sixteen unique custom relay origins. - /// - Throws: ``CmxIrohRelayPolicyError/invalidSelection`` for an invalid set. - public init(relays: [CmxIrohCustomRelay]) throws { - guard (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains(relays.count), - Set(relays.map(\.url)).count == relays.count else { - throw CmxIrohRelayPolicyError.invalidSelection - } - self.relays = relays - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProfileStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProfileStore.swift deleted file mode 100644 index 1ebd701b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohCustomRelayProfileStore.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation - -/// The device-local relay choice restored at app startup. -public enum CmxIrohCustomRelaySelection: Equatable, Sendable { - /// Use the managed relay policy. - case managed - - /// Use the complete validated custom relay override. - case custom(CmxIrohCustomRelayProfile) - - /// Keep managed relays disabled because a selected custom profile is unavailable. - case customUnavailable -} - -/// Persists a user-controlled relay override and its tokens in device-local secure storage. -public actor CmxIrohCustomRelayProfileStore { - private struct RelayRecord: Codable { - let url: String - let authenticationToken: String? - } - - private struct Record: Codable { - let version: Int - let relays: [RelayRecord] - } - - private static let storageAccount = "active-custom-relay-profile" - private static let selectionKey = "cmux.iroh.custom-relay-selection.v1" - private static let customSelectionValue = "custom" - private static let recordVersion = 1 - - private let secureStore: any CmxIrohSecureCredentialStoring - private let selectionStore: any CmxIrohInstallStateStoring - - /// Creates a custom-relay profile store. - /// - /// - Parameters: - /// - secureStore: Device-local storage for relay URLs and secret tokens. - /// - selectionStore: Non-secret marker that prevents storage failures from - /// silently relaxing a custom override back to managed relays. - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( - service: "com.cmuxterm.iroh.custom-relays.v1" - ), - selectionStore: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore() - ) { - self.secureStore = secureStore - self.selectionStore = selectionStore - } - - /// Saves the complete active custom profile. - /// - /// - Parameter profile: A validated profile whose tokens must remain device-local. - public func save(_ profile: CmxIrohCustomRelayProfile) async throws { - let record = Record( - version: Self.recordVersion, - relays: profile.relays.map { - RelayRecord(url: $0.url, authenticationToken: $0.authenticationToken) - } - ) - try await secureStore.write( - JSONEncoder().encode(record), - account: Self.storageAccount, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - selectionStore.set(Self.customSelectionValue, forKey: Self.selectionKey) - } - - /// Restores the active relay choice without weakening a custom override. - /// - /// If secure storage is locked, corrupt, or unavailable while the custom - /// marker remains set, callers receive ``customUnavailable`` and can keep - /// direct P2P enabled without enabling another relay provider. - public func loadSelection() async -> CmxIrohCustomRelaySelection { - guard selectionStore.string(forKey: Self.selectionKey) - == Self.customSelectionValue else { - return .managed - } - do { - guard let profile = try await load() else { - return .customUnavailable - } - return .custom(profile) - } catch { - return .customUnavailable - } - } - - /// Loads and revalidates the active custom profile. - /// - /// Corrupt records are deleted instead of being interpreted as relay policy. - /// - /// - Returns: The validated profile, or `nil` when none is installed. - public func load() async throws -> CmxIrohCustomRelayProfile? { - guard let data = try await secureStore.read(account: Self.storageAccount) else { - return nil - } - guard let record = try? JSONDecoder().decode(Record.self, from: data), - record.version == Self.recordVersion, - let profile = try? CmxIrohCustomRelayProfile( - relays: record.relays.map { - try CmxIrohCustomRelay( - url: $0.url, - authenticationToken: $0.authenticationToken - ) - } - ) else { - try await secureStore.delete(account: Self.storageAccount) - return nil - } - return profile - } - - /// Removes the active custom profile and every stored relay token. - public func clear() async throws { - selectionStore.set(nil, forKey: Self.selectionKey) - try await secureStore.delete(account: Self.storageAccount) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDecodedStreamHeader.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDecodedStreamHeader.swift deleted file mode 100644 index f1b5ba65..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDecodedStreamHeader.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// A decoded stream header and the number of prefix bytes it consumed. -public struct CmxIrohDecodedStreamHeader: Equatable, Sendable { - /// The validated lane declaration. - public let header: CmxIrohStreamHeader - - /// The byte offset at which application payload begins. - public let consumedByteCount: Int - - /// Creates a decoded-header result. - /// - /// - Parameters: - /// - header: The validated stream header. - /// - consumedByteCount: The exact number of framing bytes consumed. - public init(header: CmxIrohStreamHeader, consumedByteCount: Int) { - self.header = header - self.consumedByteCount = consumedByteCount - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeferredByteTransport.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeferredByteTransport.swift deleted file mode 100644 index c9fa1087..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeferredByteTransport.swift +++ /dev/null @@ -1,82 +0,0 @@ -import CMUXMobileCore -import Foundation - -/// Defers transport construction until the signed-in runtime finishes activation. -actor CmxIrohDeferredByteTransport: CmxByteTransport { - private let request: CmxByteTransportRequest - private let provider: any CmxIrohDeferredTransportProviding - private var connectTask: Task<any CmxByteTransport, any Error>? - private var transport: (any CmxByteTransport)? - private var closed = false - - init( - request: CmxByteTransportRequest, - provider: any CmxIrohDeferredTransportProviding - ) { - self.request = request - self.provider = provider - } - - func connect() async throws { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - if transport != nil { return } - - let task: Task<any CmxByteTransport, any Error> - if let connectTask { - task = connectTask - } else { - let request = request - let provider = provider - task = Task { - let transport = try await provider.transport(for: request) - do { - try await transport.connect() - try Task.checkCancellation() - return transport - } catch { - await transport.close() - throw error - } - } - connectTask = task - } - - do { - let connected = try await withTaskCancellationHandler( - operation: { try await task.value }, - onCancel: { task.cancel() } - ) - guard !closed else { - await connected.close() - throw CmxIrohByteTransportError.alreadyClosed - } - transport = connected - connectTask = nil - } catch { - connectTask = nil - throw error - } - } - - func receive() async throws -> Data? { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - guard let transport else { throw CmxIrohByteTransportError.notConnected } - return try await transport.receive() - } - - func send(_ data: Data) async throws { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - guard let transport else { throw CmxIrohByteTransportError.notConnected } - try await transport.send(data) - } - - func close() async { - guard !closed else { return } - closed = true - connectTask?.cancel() - connectTask = nil - let closing = transport - transport = nil - await closing?.close() - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeferredTransportProviding.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeferredTransportProviding.swift deleted file mode 100644 index ba4970a6..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeferredTransportProviding.swift +++ /dev/null @@ -1,14 +0,0 @@ -public import CMUXMobileCore - -/// Resolves an active Iroh transport after account-scoped startup completes. -public protocol CmxIrohDeferredTransportProviding: Sendable { - /// Builds a transport from the currently active account-scoped runtime. - /// - /// - Parameter request: The exact peer route and intended Mac binding. - /// - Returns: A disconnected transport owned by the active runtime. - /// - Throws: ``CmxIrohClientRuntimeError/inactive`` until startup completes, - /// or a route validation error. - func transport( - for request: CmxByteTransportRequest - ) async throws -> any CmxByteTransport -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDevelopmentFileStorage.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDevelopmentFileStorage.swift deleted file mode 100644 index 2a1c7b0a..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDevelopmentFileStorage.swift +++ /dev/null @@ -1,195 +0,0 @@ -public import Foundation - -/// DEBUG-only endpoint-identity persistence for ad-hoc app builds. -/// -/// Ad-hoc macOS and Simulator builds have no provisioning-profile Keychain -/// access group, so the data-protection Keychain returns -/// `errSecMissingEntitlement`. Production compositions must keep using -/// ``CmxIrohKeychainIdentityStore``. -public final class CmxIrohDevelopmentFileIdentityStore: - CmxIrohSecureIdentityStoring, - @unchecked Sendable -{ - private let directory: URL - - /// Creates a store inside a tag-specific application-support directory. - public init(directory: URL) { - self.directory = directory - } - - public func read(account: String) throws -> Data? { - try CmxIrohDevelopmentFileStorage.read( - account: account, - directory: directory - ) - } - - public func write(_ data: Data, account: String) throws { - try CmxIrohDevelopmentFileStorage.write( - data, - account: account, - directory: directory - ) - } - - public func delete(account: String) throws { - try CmxIrohDevelopmentFileStorage.delete( - account: account, - directory: directory - ) - } - - public func deleteAll() throws { - try CmxIrohDevelopmentFileStorage.deleteAll(in: directory) - } -} - -/// DEBUG-only capability persistence for ad-hoc app builds. -/// -/// Records remain scoped to the app sandbox and are written with 0600 mode. -/// Production compositions must keep using -/// ``CmxIrohKeychainCredentialStore`` so capabilities receive hardware-backed -/// data protection where the platform provides it. -public actor CmxIrohDevelopmentFileCredentialStore: - CmxIrohSecureCredentialStoring -{ - private let directory: URL - - /// Creates a store inside a tag-specific application-support directory. - public init(directory: URL) { - self.directory = directory - } - - public func read(account: String) throws -> Data? { - try CmxIrohDevelopmentFileStorage.read( - account: account, - directory: directory - ) - } - - public func write( - _ data: Data, - account: String, - accessibility _: CmxIrohSecureCredentialAccessibility - ) throws { - try CmxIrohDevelopmentFileStorage.write( - data, - account: account, - directory: directory - ) - } - - public func delete(account: String) throws { - try CmxIrohDevelopmentFileStorage.delete( - account: account, - directory: directory - ) - } - - public func deleteAll() throws { - try CmxIrohDevelopmentFileStorage.deleteAll(in: directory) - } -} - -private enum CmxIrohDevelopmentFileStorage { - private static let maximumRecordByteCount = 8 * 1_024 * 1_024 - private static let recordExtension = "cmux-iroh" - - static func read(account: String, directory: URL) throws -> Data? { - let file = try recordURL(account: account, directory: directory) - guard FileManager.default.fileExists(atPath: file.path) else { - return nil - } - do { - let data = try Data(contentsOf: file, options: [.mappedIfSafe]) - guard data.count <= maximumRecordByteCount else { - throw CmxIrohDevelopmentFileStoreError.recordTooLarge - } - return data - } catch let error as CmxIrohDevelopmentFileStoreError { - throw error - } catch { - throw CmxIrohDevelopmentFileStoreError.storageFailure - } - } - - static func write(_ data: Data, account: String, directory: URL) throws { - guard data.count <= maximumRecordByteCount else { - throw CmxIrohDevelopmentFileStoreError.recordTooLarge - } - let file = try recordURL(account: account, directory: directory) - do { - try prepare(directory: directory) - try data.write(to: file, options: [.atomic]) - try FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: file.path - ) - } catch { - throw CmxIrohDevelopmentFileStoreError.storageFailure - } - } - - static func delete(account: String, directory: URL) throws { - let file = try recordURL(account: account, directory: directory) - guard FileManager.default.fileExists(atPath: file.path) else { return } - do { - try FileManager.default.removeItem(at: file) - } catch { - throw CmxIrohDevelopmentFileStoreError.storageFailure - } - } - - static func deleteAll(in directory: URL) throws { - guard FileManager.default.fileExists(atPath: directory.path) else { - return - } - do { - let records = try FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] - ) - for record in records where record.pathExtension == recordExtension { - let values = try record.resourceValues(forKeys: [.isRegularFileKey]) - guard values.isRegularFile == true else { continue } - try FileManager.default.removeItem(at: record) - } - } catch { - throw CmxIrohDevelopmentFileStoreError.storageFailure - } - } - - private static func prepare(directory: URL) throws { - try FileManager.default.createDirectory( - at: directory, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], - ofItemAtPath: directory.path - ) - var values = URLResourceValues() - values.isExcludedFromBackup = true - var mutableDirectory = directory - try mutableDirectory.setResourceValues(values) - } - - private static func recordURL(account: String, directory: URL) throws -> URL { - guard !account.isEmpty, - account.utf8.count <= 1_024, - account.unicodeScalars.allSatisfy({ scalar in - switch scalar.value { - case 45, 46, 48...57, 65...90, 95, 97...122: - true - default: - false - } - }) else { - throw CmxIrohDevelopmentFileStoreError.invalidAccount - } - return directory.appendingPathComponent(account) - .appendingPathExtension(recordExtension) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDevelopmentFileStoreError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDevelopmentFileStoreError.swift deleted file mode 100644 index 912689b1..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDevelopmentFileStoreError.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Failure from the DEBUG-only file persistence used by ad-hoc app builds. -public enum CmxIrohDevelopmentFileStoreError: Error, Equatable, Sendable { - /// The opaque repository scope is not safe to use as one path component. - case invalidAccount - - /// The record exceeds the defensive per-record development limit. - case recordTooLarge - - /// The sandboxed file operation failed. - case storageFailure -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeviceID.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeviceID.swift deleted file mode 100644 index 5a706cc3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDeviceID.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -/// A syntactically valid device UUID normalized for identity comparisons. -struct CmxIrohDeviceID: Equatable, Sendable { - let value: String - - init?(_ rawValue: String) { - guard let uuid = UUID(uuidString: rawValue) else { return nil } - let canonical = uuid.uuidString.lowercased() - guard rawValue.lowercased() == canonical else { return nil } - value = canonical - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift deleted file mode 100644 index 29bcc394..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiagnosticFailure.swift +++ /dev/null @@ -1,205 +0,0 @@ -public import CMUXMobileCore - -// These conformances are deliberately categorical. They prevent callers from -// exporting `String(describing: error)`, which may contain endpoint identities, -// relay URLs, credentials, or private network addresses. - -extension CmxIrohTrustBrokerClientError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .connectivity: - .offline - case .missingAuthentication, .invalidAuthentication: - .authorizationFailed - case .rateLimited: - .policyUnavailable - case let .rejected(statusCode, _): - switch statusCode { - case 401, 403: .authorizationFailed - case 408: .timedOut - default: .policyUnavailable - } - case .invalidBaseURL, .nonHTTPResponse, .invalidResponse: - .protocolViolation - } - } -} - -extension CmxIrohByteTransportError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .unsupportedRouteKind, .unsupportedEndpoint: - .unsupportedRoute - case .missingPeerIntent: - .authorizationFailed - case .alreadyClosed, .notConnected, .controlLaneAlreadyOwned: - .connectionClosed - } - } -} - -extension CmxIrohClientRuntimeError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .inactive, .alreadyActive: - .endpointUnavailable - case .invalidLocalBinding, .localBindingMissingFromDiscovery: - .identityMismatch - case .relayFleetMismatch: - .policyUnavailable - case .routeContractMismatch: - .protocolViolation - case .superseded: - .superseded - } - } -} - -extension CmxIrohHostRuntimeError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .inactive, .alreadyActive: - .endpointUnavailable - case .invalidLocalBinding, .localBindingMissingFromDiscovery: - .identityMismatch - case .relayFleetMismatch: - .policyUnavailable - case .routeContractMismatch: - .protocolViolation - case .superseded: - .superseded - } - } -} - -extension CmxIrohClientSessionError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .remoteIdentityMismatch: - .identityMismatch - case .admissionDenied: - .admissionDenied - case .alreadyClosed, .notConnected, .unexpectedEndOfStream: - .connectionClosed - case .invalidAdmissionFrame, .invalidMaximumByteCount, - .invalidOutgoingLane, .applicationLanesUnavailable: - .protocolViolation - } - } -} - -extension CmxIrohServerSessionError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .admissionDenied: - .admissionDenied - case .alreadyClosed, .notAdmitted, .unexpectedEndOfStream: - .connectionClosed - case .streamHeaderTimedOut: - .timedOut - case .alreadyAdmitted, .invalidAdmissionFrame, .invalidFirstLane, - .invalidPeerLane, .invalidServerLane, .applicationLanesUnavailable, - .applicationLaneRejected: - .protocolViolation - } - } -} - -extension CmxIrohLibError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .invalidEndpointIdentity, .remoteIdentityMismatch: - .identityMismatch - case .expiredRelayCredential: - .credentialUnavailable - case .unmanagedRelayURL, .unsupportedRelayIdentifier: - .policyUnavailable - case .unexpectedALPN, .invalidReceiveLimit: - .protocolViolation - } - } -} - -extension CmxIrohEndpointSupervisorError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .inactive: .endpointUnavailable - case .relayReadinessTimedOut: .endpointUnavailable - case .superseded: .superseded - } - } -} - -extension CmxIrohRelayPolicyServiceError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .brokerUnavailable: .policyUnavailable - case .managedCredentialUnavailable: .credentialUnavailable - case .preferenceRollback: .policyUnavailable - case .superseded: .superseded - } - } -} - -extension CmxIrohRelayCredentialCoordinatorError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .inactive: .endpointUnavailable - case .relayFleetMismatch: .policyUnavailable - } - } -} - -extension CmxIrohRegistryContextError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .unsupportedRoute, .dialPlanUnavailable: - .noRoute - case .incompatibleContract: - .protocolViolation - case .relayFleetMismatch, .invalidGrantExpiry: - .policyUnavailable - case .localBindingUnavailable, .targetBindingUnavailable: - .endpointUnavailable - case .targetDeviceMismatch: - .identityMismatch - case .targetNotPairable: - .authorizationFailed - } - } -} - -extension CmxIrohGrantVerifierError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .identityMismatch: - .identityMismatch - case .accountMismatch: - .accountMismatch - case .expired: - .authorizationFailed - case .invalidKeySet, .invalidToken, .invalidHeader, .unknownKeyID, - .invalidSignature, .invalidClaims: - .protocolViolation - } - } -} - -extension CmxIrohPrivateFallbackValidationError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { - switch self { - case .unavailable, .profileUnavailable, .hintExpiredOrInvalid: - .noRoute - case .authorizationMismatch, .generationChanged: - .authorizationFailed - } - } -} - -extension CmxIrohKeychainCredentialStoreError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { .credentialUnavailable } -} - -extension CmxIrohKeychainIdentityStoreError: DiagnosticFailureProviding { - public var diagnosticFailureKind: DiagnosticFailureKind { .credentialUnavailable } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift deleted file mode 100644 index 0219c624..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDirectPorts.swift +++ /dev/null @@ -1,114 +0,0 @@ -import CMUXMobileCore - -/// The endpoint-observed UDP ports for private IPv4 and IPv6 Iroh paths. -/// -/// The broker may disclose these ports only to the same authenticated account. -/// A port contains no private address and never contributes peer identity or -/// authorization. Clients combine it with a locally known private address and -/// still pin the QUIC handshake to the broker-authenticated EndpointID. -public struct CmxIrohDirectPorts: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case ipv4 - case ipv6 - } - - public let ipv4: UInt16? - public let ipv6: UInt16? - - public init(ipv4: UInt16? = nil, ipv6: UInt16? = nil) throws { - guard ipv4 != nil || ipv6 != nil, - ipv4.map({ $0 != 0 }) ?? true, - ipv6.map({ $0 != 0 }) ?? true else { - throw CmxIrohDirectPortsError.empty - } - self.ipv4 = ipv4 - self.ipv6 = ipv6 - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - ipv4: container.decodeIfPresent(UInt16.self, forKey: .ipv4), - ipv6: container.decodeIfPresent(UInt16.self, forKey: .ipv6) - ) - } - - /// Derives one unambiguous socket port per address family from the endpoint. - /// - /// Iroh may bind IPv4 and IPv6 independently. If one family reports more - /// than one port, that family is omitted rather than guessing which private - /// coordinate is authoritative. - init?(localDirectAddresses: [String]) { - var ipv4Ports: Set<UInt16> = [] - var ipv6Ports: Set<UInt16> = [] - var ipv4WildcardPorts: Set<UInt16> = [] - var ipv6WildcardPorts: Set<UInt16> = [] - for rawAddress in localDirectAddresses { - if let wildcard = CmxIrohLANSocketAddress.wildcard(rawAddress) { - switch wildcard.family { - case .ipv4: - ipv4Ports.insert(wildcard.port) - ipv4WildcardPorts.insert(wildcard.port) - case .ipv6: - ipv6Ports.insert(wildcard.port) - ipv6WildcardPorts.insert(wildcard.port) - } - continue - } - guard let address = try? CmxIrohLANSocketAddress(rawAddress) else { - continue - } - switch address.family { - case .ipv4: ipv4Ports.insert(address.port) - case .ipv6: ipv6Ports.insert(address.port) - } - } - let ipv4 = Self.authoritativePort( - wildcardPorts: ipv4WildcardPorts, - observedPorts: ipv4Ports - ) - let ipv6 = Self.authoritativePort( - wildcardPorts: ipv6WildcardPorts, - observedPorts: ipv6Ports - ) - guard ipv4 != nil || ipv6 != nil else { return nil } - self.ipv4 = ipv4 - self.ipv6 = ipv6 - } - - func port(forDirectAddress value: String) -> UInt16? { - value.hasPrefix("[") ? ipv6 : ipv4 - } - - private static func authoritativePort( - wildcardPorts: Set<UInt16>, - observedPorts: Set<UInt16> - ) -> UInt16? { - if wildcardPorts.count == 1 { return wildcardPorts.first } - return observedPorts.count == 1 ? observedPorts.first : nil - } - - func replacingPort(in hint: CmxIrohPathHint) -> CmxIrohPathHint? { - guard hint.kind == .directAddress, - hint.privacyScope != .publicInternet, - hint.source == .tailscale || hint.source == .customVPN else { - return hint - } - guard let port = port(forDirectAddress: hint.value), - let separator = hint.value.lastIndex(of: ":") else { return nil } - let value = String(hint.value[...separator]) + String(port) - return try? CmxIrohPathHint( - kind: hint.kind, - value: value, - source: hint.source, - privacyScope: hint.privacyScope, - observedAt: hint.observedAt, - expiresAt: hint.expiresAt, - networkProfile: hint.networkProfile - ) - } -} - -public enum CmxIrohDirectPortsError: Error, Equatable, Sendable { - case empty -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiscoveryServing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiscoveryServing.swift deleted file mode 100644 index f867e1c5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohDiscoveryServing.swift +++ /dev/null @@ -1,4 +0,0 @@ -/// Authenticated broker boundary that returns current endpoint policy. -public protocol CmxIrohDiscoveryServing: Sendable { - func discover() async throws -> CmxIrohDiscoveryResponse -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift deleted file mode 100644 index fe178327..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEffectiveRelayPolicy.swift +++ /dev/null @@ -1,68 +0,0 @@ -/// Fully resolved relay configuration safe to install on an Iroh endpoint. -public struct CmxIrohEffectiveRelayPolicy: Equatable, Sendable { - /// Exact endpoint relay allowlist and available credentials. - public let endpointRelayProfile: CmxIrohEndpointRelayProfile - - /// Verified managed policy snapshot, absent for custom and unavailable modes. - public let managedSnapshot: CmxIrohRelayPolicySnapshot? - - /// Latest verified managed catalog, retained even when custom or direct-only is active. - public let managedPolicy: CmxIrohManagedRelayPolicy? - - /// Complete account configuration requested by the broker. - public let requestedConfiguration: CmxIrohAccountRelayConfiguration? - - /// Active preference derived from ``requestedConfiguration``. - public var requestedPreference: CmxIrohAccountRelayPreference? { - requestedConfiguration?.activePreference - } - - /// Preference subset that could safely be honored. - public let effectivePreference: CmxIrohAccountRelayPreference? - - /// Requested managed IDs that are absent from the verified policy. - public let staleRelayIDs: Set<String> - - /// Custom relay IDs whose required device-local token is absent. - public let missingCredentialRelayIDs: Set<String> - - /// Origin and availability of the endpoint profile. - public let source: CmxIrohRelayPolicySource - - /// Whether the managed policy came from the last-known-good cache. - public let usedCachedPolicy: Bool - - /// Monotonic broker preference revision, when one was restored. - public let preferenceRevision: Int64? - - /// The endpoint-scoped credential returned with this exact broker policy. - /// - /// Kept internal so tokens cannot cross the transport/settings boundary. - let relayBootstrap: CmxIrohRelayTokenResponse? - - init( - endpointRelayProfile: CmxIrohEndpointRelayProfile, - managedSnapshot: CmxIrohRelayPolicySnapshot?, - managedPolicy: CmxIrohManagedRelayPolicy?, - requestedConfiguration: CmxIrohAccountRelayConfiguration?, - effectivePreference: CmxIrohAccountRelayPreference?, - staleRelayIDs: Set<String> = [], - missingCredentialRelayIDs: Set<String> = [], - source: CmxIrohRelayPolicySource, - usedCachedPolicy: Bool, - preferenceRevision: Int64?, - relayBootstrap: CmxIrohRelayTokenResponse? = nil - ) { - self.endpointRelayProfile = endpointRelayProfile - self.managedSnapshot = managedSnapshot - self.managedPolicy = managedPolicy - self.requestedConfiguration = requestedConfiguration - self.effectivePreference = effectivePreference - self.staleRelayIDs = staleRelayIDs - self.missingCredentialRelayIDs = missingCredentialRelayIDs - self.source = source - self.usedCachedPolicy = usedCachedPolicy - self.preferenceRevision = preferenceRevision - self.relayBootstrap = relayBootstrap - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift deleted file mode 100644 index c15df53f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpoint.swift +++ /dev/null @@ -1,75 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// One actor-isolated Iroh endpoint generation. -public protocol CmxIrohEndpoint: Sendable { - /// Returns the stable EndpointID derived from the injected secret key. - func identity() async -> CmxIrohPeerIdentity - - /// Returns the endpoint's current public reachability snapshot. - func address() async -> CmxIrohEndpointAddress - - /// Returns the driver's raw direct-address snapshot for local-only policy. - /// - /// These values must never be copied into broker registration. The LAN - /// publisher intersects them with current interface addresses before any - /// Bonjour registration. - func localDirectAddresses() async -> [String] - - /// Connects to the expected peer using only the supplied attempt's hints. - /// - /// - Parameters: - /// - address: The expected EndpointID and reachability hints. - /// - alpn: The exact ALPN to negotiate. - /// - Returns: The TLS-authenticated connection. - /// - Throws: A transport error or `CancellationError`. - func connect( - to address: CmxIrohEndpointAddress, - alpn: Data - ) async throws -> any CmxIrohConnection - - /// Accepts the next connection that negotiated a configured ALPN. - /// - /// - Returns: The accepted connection, or `nil` after endpoint close. - /// - Throws: A transport error for a failed handshake. - func accept() async throws -> (any CmxIrohConnection)? - - /// Replaces relay credentials without changing the EndpointID. - /// - /// - Parameter relays: The new complete managed relay set. - /// - Throws: A transport error when the update cannot be applied. - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws - - /// Replaces the complete managed or custom relay profile without changing EndpointID. - /// - /// - Parameter profile: The exact new allowlist and active relay configurations. - /// - Throws: A transport error when the update cannot be applied atomically. - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws - - /// Emits network and unexpected-driver lifecycle signals. - /// - /// - Returns: A generation-scoped health stream that finishes on close. - func healthEvents() async -> AsyncStream<CmxIrohEndpointHealthEvent> - - /// Returns whether the underlying endpoint driver can still serve this generation. - /// - /// This snapshot is used when an app returns to the foreground, where iOS - /// may have suspended delivery of the driver's terminal event. - func isHealthy() async -> Bool - - /// Closes the endpoint and cancels its network work. - func close() async -} - -public extension CmxIrohEndpoint { - /// Test and alternate endpoints opt out of local advertisement by default. - func localDirectAddresses() async -> [String] { [] } - - /// Alternate endpoints retain managed credential refresh compatibility. - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { - guard profile.source == .managed else { - throw CmxIrohEndpointConfigurationError.unsupportedRelayProfileReplacement - } - try await replaceRelays(profile.managedRelays) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointAddress.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointAddress.swift deleted file mode 100644 index 6cd5d213..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointAddress.swift +++ /dev/null @@ -1,20 +0,0 @@ -public import CMUXMobileCore - -/// A peer EndpointID plus untrusted reachability hints supplied to Iroh. -public struct CmxIrohEndpointAddress: Equatable, Sendable { - /// The TLS-authenticated peer identity. - public let identity: CmxIrohPeerIdentity - - /// The bounded hints for exactly one dial attempt. - public let pathHints: [CmxIrohPathHint] - - /// Creates a dial address for one public or private attempt. - /// - /// - Parameters: - /// - identity: The expected peer EndpointID. - /// - pathHints: Reachability hints that never contribute authorization. - public init(identity: CmxIrohPeerIdentity, pathHints: [CmxIrohPathHint]) { - self.identity = identity - self.pathHints = pathHints - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointBindPolicy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointBindPolicy.swift deleted file mode 100644 index f49a688b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointBindPolicy.swift +++ /dev/null @@ -1,24 +0,0 @@ -/// Selects whether Iroh may choose an ephemeral UDP port or must bind one address. -public enum CmxIrohEndpointBindPolicy: Equatable, Sendable { - /// Uses Iroh's default dual-stack sockets with OS-assigned ports. - case ephemeral - - /// Tries an exact address, then uses default ephemeral sockets on collision. - case preferred(CmxIrohBindAddress) - - /// Requires the exact IP address and port. A collision fails endpoint activation. - case required(CmxIrohBindAddress) - - var socketAddress: String? { - switch self { - case .ephemeral: - nil - case let .preferred(address), let .required(address): - address.socketAddress - } - } - - var allowsEphemeralFallback: Bool { - if case .preferred = self { true } else { false } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift deleted file mode 100644 index b0adb818..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfiguration.swift +++ /dev/null @@ -1,71 +0,0 @@ -public import Foundation - -/// The complete immutable input used to bind one Iroh endpoint generation. -public struct CmxIrohEndpointConfiguration: Equatable, Sendable { - /// The device-local secret that preserves EndpointID across recreation. - public let secretKey: CmxIrohSecretKey - - /// The application protocols accepted by this endpoint. - public let alpns: [Data] - - /// The endpoint's local UDP socket policy. - public let bindPolicy: CmxIrohEndpointBindPolicy - - /// The exact relay policy used by this endpoint generation. - public let relayProfile: CmxIrohEndpointRelayProfile - - /// The exact managed relay origins allowed for this build or policy. - public var managedRelayURLs: Set<String> { - relayProfile.source == .managed ? relayProfile.allowedRelayURLs : [] - } - - /// Endpoint-scoped credentials for some or all allowed relays. - public var relays: [CmxIrohRelayConfiguration] { - relayProfile.managedRelays - } - - /// Creates a validated endpoint bind configuration. - /// - /// - Parameters: - /// - secretKey: The stable endpoint key. - /// - alpns: ALPNs advertised by the endpoint. - /// - bindPolicy: Ephemeral by default, or an exact required socket address. - /// - managedRelayURLs: Exact relay origins permitted by app or MDM policy. - /// - relays: Current endpoint-scoped relay credentials. - /// - Throws: ``CmxIrohEndpointConfigurationError`` for fleet-policy violations. - public init( - secretKey: CmxIrohSecretKey, - alpns: [Data], - bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - managedRelayURLs: Set<String>, - relays: [CmxIrohRelayConfiguration] - ) throws { - let relayProfile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: managedRelayURLs, - relays: relays - ) - self.secretKey = secretKey - self.alpns = alpns - self.bindPolicy = bindPolicy - self.relayProfile = relayProfile - } - - /// Creates an endpoint with an already validated relay profile. - /// - /// - Parameters: - /// - secretKey: The stable endpoint key. - /// - alpns: ALPNs advertised by the endpoint. - /// - bindPolicy: Ephemeral by default, or an exact required socket address. - /// - relayProfile: The complete managed or custom relay policy. - public init( - secretKey: CmxIrohSecretKey, - alpns: [Data], - bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - relayProfile: CmxIrohEndpointRelayProfile - ) { - self.secretKey = secretKey - self.alpns = alpns - self.bindPolicy = bindPolicy - self.relayProfile = relayProfile - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift deleted file mode 100644 index 1a6a2b5d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointConfigurationError.swift +++ /dev/null @@ -1,20 +0,0 @@ -/// Validation failures for an Iroh endpoint bind configuration. -public enum CmxIrohEndpointConfigurationError: Error, Equatable, Sendable { - /// The relay fleet is larger than the endpoint policy permits. - case tooManyRelays(Int) - - /// A relay URL appears more than once. - case duplicateRelayURL(String) - - /// A credential names a relay outside the explicit fleet allowlist. - case unmanagedRelayURL(String) - - /// A verified managed selection is missing one or more relay credentials. - case incompleteManagedRelayCredentials - - /// Managed broker credentials cannot mutate a strict custom relay override. - case managedCredentialUpdateInCustomProfile - - /// The endpoint implementation cannot apply a complete profile replacement. - case unsupportedRelayProfileReplacement -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointFactory.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointFactory.swift deleted file mode 100644 index b6c16dfd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointFactory.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Binds concrete Iroh endpoint generations behind a testable seam. -public protocol CmxIrohEndpointFactory: Sendable { - /// Binds one endpoint from a stable key and current relay credentials. - /// - /// - Parameter configuration: The complete immutable bind input. - /// - Returns: A new active endpoint generation. - /// - Throws: A transport or platform configuration error. - func bind( - configuration: CmxIrohEndpointConfiguration - ) async throws -> any CmxIrohEndpoint -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointHealthEvent.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointHealthEvent.swift deleted file mode 100644 index faa143be..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointHealthEvent.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// A lifecycle signal emitted by an active Iroh endpoint generation. -public enum CmxIrohEndpointHealthEvent: Equatable, Sendable { - /// Iroh completed its initial network discovery work. - case online - - /// The local network changed and reachability should be republished. - case networkChanged - - /// The endpoint driver stopped without an explicit lifecycle close. - case closedUnexpectedly -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointLifecycleState.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointLifecycleState.swift deleted file mode 100644 index 8f12165b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointLifecycleState.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// The coarse lifecycle of a supervised Iroh endpoint. -public enum CmxIrohEndpointLifecycleState: Equatable, Sendable { - /// The app does not currently want an endpoint bound. - case inactive - - /// A new endpoint generation is binding. - case starting - - /// The generation is bound and may accept or create connections. - case active - - /// The most recent bind or recovery attempt failed. - case failed -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift deleted file mode 100644 index e0718237..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointRelayProfile.swift +++ /dev/null @@ -1,168 +0,0 @@ -import Foundation - -/// The complete relay policy installed on one Iroh endpoint generation. -public struct CmxIrohEndpointRelayProfile: Equatable, Sendable { - enum Source: Equatable, Sendable { - case managed - case custom - } - - struct Relay: Equatable, Sendable { - let url: String - let authenticationToken: String? - let expiresAt: Date? - - func isUsable(at now: Date) -> Bool { - expiresAt.map { $0 > now } ?? true - } - } - - /// Exact relay origins accepted in peer reachability hints. - public let allowedRelayURLs: Set<String> - - let source: Source - let activeRelays: [Relay] - let managedRelays: [CmxIrohRelayConfiguration] - - /// A fail-closed profile used when a selected custom relay profile cannot - /// be restored. Direct P2P stays enabled, while every relay is disabled. - public static let unavailableCustomOverride = CmxIrohEndpointRelayProfile( - allowedRelayURLs: [], - source: .custom, - activeRelays: [], - managedRelays: [] - ) - - /// A fail-closed profile used when a managed relay selection cannot be - /// honored. Direct P2P stays enabled, while every relay is disabled. - public static let unavailableManagedSelection = CmxIrohEndpointRelayProfile( - allowedRelayURLs: [], - source: .managed, - activeRelays: [], - managedRelays: [] - ) - - private init( - allowedRelayURLs: Set<String>, - source: Source, - activeRelays: [Relay], - managedRelays: [CmxIrohRelayConfiguration] - ) { - self.allowedRelayURLs = allowedRelayURLs - self.source = source - self.activeRelays = activeRelays - self.managedRelays = managedRelays - } - - /// Creates a managed profile whose credentials are constrained by an - /// app-pinned or root-verified relay allowlist. - /// - /// The allowlist may contain relays without a current credential so an - /// endpoint can bind before broker refresh completes. - /// - /// - Parameters: - /// - allowedRelayURLs: Exact managed relay origins accepted by policy. - /// - relays: Current endpoint-scoped credentials for a subset of the allowlist. - /// - Throws: ``CmxIrohEndpointConfigurationError`` for a policy violation. - public init( - managedRelayURLs allowedRelayURLs: Set<String>, - relays: [CmxIrohRelayConfiguration] - ) throws { - try Self.validate( - allowedRelayURLs: allowedRelayURLs, - relayURLs: relays.map(\.url) - ) - self.allowedRelayURLs = allowedRelayURLs - source = .managed - activeRelays = relays.map { - Relay( - url: $0.url, - authenticationToken: $0.token, - expiresAt: $0.expiresAt - ) - } - managedRelays = relays - } - - /// Creates a managed profile from one verified catalog selection and its - /// exact endpoint-scoped credential set. - /// - /// - Parameters: - /// - snapshot: Root-verified managed catalog and local selection. - /// - relays: Credentials for every selected relay and no other origin. - /// - Throws: ``CmxIrohEndpointConfigurationError`` for credential substitution. - public init( - snapshot: CmxIrohRelayPolicySnapshot, - relays: [CmxIrohRelayConfiguration] - ) throws { - let selectedURLs = snapshot.relayURLs - let credentialURLs = Set(relays.map(\.url)) - guard credentialURLs == selectedURLs else { - if let substituted = credentialURLs.subtracting(selectedURLs).first { - throw CmxIrohEndpointConfigurationError.unmanagedRelayURL(substituted) - } - throw CmxIrohEndpointConfigurationError.incompleteManagedRelayCredentials - } - try self.init(managedRelayURLs: selectedURLs, relays: relays) - } - - /// Creates a strict custom override with no managed-provider fallback. - /// - /// Direct peer-to-peer paths remain enabled by Iroh. This profile controls - /// only which relays may carry traffic when direct connectivity is unavailable. - /// - /// - Parameter customProfile: User-controlled relays and optional static tokens. - public init(customProfile: CmxIrohCustomRelayProfile) { - allowedRelayURLs = Set(customProfile.relays.map(\.url)) - source = .custom - activeRelays = customProfile.relays.map { - Relay( - url: $0.url, - authenticationToken: $0.authenticationToken, - expiresAt: nil - ) - } - managedRelays = [] - } - - func replacingManagedRelays( - _ relays: [CmxIrohRelayConfiguration] - ) throws -> CmxIrohEndpointRelayProfile { - guard source == .managed else { - throw CmxIrohEndpointConfigurationError.managedCredentialUpdateInCustomProfile - } - return try CmxIrohEndpointRelayProfile( - managedRelayURLs: allowedRelayURLs, - relays: relays - ) - } - - func droppingExpiredManagedCredentials(at now: Date) throws -> CmxIrohEndpointRelayProfile { - guard source == .managed else { return self } - return try CmxIrohEndpointRelayProfile( - managedRelayURLs: allowedRelayURLs, - relays: managedRelays.filter { $0.expiresAt > now } - ) - } - - private static func validate( - allowedRelayURLs: Set<String>, - relayURLs: [String] - ) throws { - guard allowedRelayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohEndpointConfigurationError.tooManyRelays(allowedRelayURLs.count) - } - guard relayURLs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohEndpointConfigurationError.tooManyRelays(relayURLs.count) - } - var observedURLs = Set<String>() - for url in relayURLs { - guard allowedRelayURLs.contains(url) else { - throw CmxIrohEndpointConfigurationError.unmanagedRelayURL(url) - } - guard observedURLs.insert(url).inserted else { - throw CmxIrohEndpointConfigurationError.duplicateRelayURL(url) - } - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift deleted file mode 100644 index 066e533c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointServer.swift +++ /dev/null @@ -1,331 +0,0 @@ -import CMUXMobileCore -public import Foundation - -/// Generation-scoped accept loop with bounded, timed admission work. -public actor CmxIrohEndpointServer { - private static let acceptRetryDelay: TimeInterval = 0.1 - - public typealias ConnectionHandler = @Sendable ( - _ connection: any CmxIrohConnection, - _ runtimeGeneration: UInt64, - _ markAdmitted: @escaping AdmissionMarker - ) async throws -> Void - public typealias AdmissionMarker = @Sendable () async -> Bool - - private struct PendingAdmission { - let generation: UInt64 - let remoteIdentity: CmxIrohPeerIdentity - let connection: any CmxIrohConnection - let handlerTask: Task<Void, Never> - let deadlineTask: Task<Void, Never> - } - - private struct ActiveConnection { - let generation: UInt64 - let remoteIdentity: CmxIrohPeerIdentity - let connection: any CmxIrohConnection - let handlerTask: Task<Void, Never> - } - - private let supervisor: CmxIrohEndpointSupervisor - private let maximumPendingAdmissions: Int - private let maximumPendingAdmissionsPerIdentity: Int - private let maximumConnections: Int - private let maximumConnectionsPerIdentity: Int - private let admissionTimeout: TimeInterval - private let clock: any CmxIrohRelayClock - private let handler: ConnectionHandler - private var eventTask: Task<Void, Never>? - private var acceptTask: Task<Void, Never>? - private var pendingAdmissions: [UUID: PendingAdmission] = [:] - private var activeConnections: [UUID: ActiveConnection] = [:] - private var currentGeneration: UInt64? - - public init( - supervisor: CmxIrohEndpointSupervisor, - maximumPendingAdmissions: Int = 10, - maximumPendingAdmissionsPerIdentity: Int = 1, - maximumConnections: Int = 10, - maximumConnectionsPerIdentity: Int = 2, - admissionTimeout: TimeInterval = 15, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - handler: @escaping ConnectionHandler - ) { - precondition(maximumPendingAdmissions > 0) - precondition(maximumPendingAdmissionsPerIdentity > 0) - precondition(maximumPendingAdmissionsPerIdentity <= maximumPendingAdmissions) - precondition(maximumConnections > 0) - precondition(maximumConnectionsPerIdentity > 0) - precondition(maximumConnectionsPerIdentity <= maximumConnections) - precondition(admissionTimeout > 0) - self.supervisor = supervisor - self.maximumPendingAdmissions = maximumPendingAdmissions - self.maximumPendingAdmissionsPerIdentity = maximumPendingAdmissionsPerIdentity - self.maximumConnections = maximumConnections - self.maximumConnectionsPerIdentity = maximumConnectionsPerIdentity - self.admissionTimeout = admissionTimeout - self.clock = clock - self.handler = handler - } - - /// Begins observing endpoint generations. Calling this more than once is a no-op. - public func start() { - guard eventTask == nil else { return } - let supervisor = supervisor - eventTask = Task { [weak self] in - let events = await supervisor.events() - for await event in events { - guard !Task.isCancelled else { return } - await self?.handle(event) - } - } - } - - /// Cancels accepts and pending admissions without deactivating the shared endpoint. - public func stop() async { - eventTask?.cancel() - eventTask = nil - acceptTask?.cancel() - acceptTask = nil - currentGeneration = nil - let admissions = pendingAdmissions.values - pendingAdmissions.removeAll() - let connections = activeConnections.values - activeConnections.removeAll() - for admission in admissions { - admission.handlerTask.cancel() - admission.deadlineTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "server_stopped" - ) - } - for connection in connections { - connection.handlerTask.cancel() - await connection.connection.close( - errorCode: 1, - reason: "server_stopped" - ) - } - } - - /// Whether `generation` is still the endpoint accepted by this server. - public func isCurrent(runtimeGeneration generation: UInt64) -> Bool { - currentGeneration == generation && acceptTask != nil - } - - private func handle(_ event: CmxIrohEndpointSupervisorEvent) async { - guard case let .snapshot(snapshot) = event else { return } - guard snapshot.state == .active else { - acceptTask?.cancel() - acceptTask = nil - currentGeneration = nil - await cancelConnections(exceptGeneration: nil, reason: "endpoint_inactive") - return - } - guard currentGeneration != snapshot.runtimeGeneration || acceptTask == nil else { - return - } - acceptTask?.cancel() - await cancelConnections( - exceptGeneration: snapshot.runtimeGeneration, - reason: "stale_generation" - ) - guard let endpoint = try? await supervisor.activeEndpoint() else { return } - currentGeneration = snapshot.runtimeGeneration - let generation = snapshot.runtimeGeneration - acceptTask = Task { [weak self] in - await self?.acceptLoop(endpoint: endpoint, generation: generation) - } - } - - private func acceptLoop( - endpoint: any CmxIrohEndpoint, - generation: UInt64 - ) async { - while !Task.isCancelled, currentGeneration == generation { - do { - guard let connection = try await endpoint.accept() else { return } - guard currentGeneration == generation else { - await connection.close(errorCode: 1, reason: "stale_generation") - return - } - await startAdmission(connection: connection, generation: generation) - } catch is CancellationError { - return - } catch { - guard currentGeneration == generation else { return } - do { - let snapshot = try await supervisor.ensureHealthy() - guard snapshot.runtimeGeneration == generation else { return } - try await clock.sleep( - until: clock.now().addingTimeInterval(Self.acceptRetryDelay) - ) - } catch { - return - } - } - } - } - - private func startAdmission( - connection: any CmxIrohConnection, - generation: UInt64 - ) async { - let remoteIdentity = await connection.remoteIdentity() - guard currentGeneration == generation, !Task.isCancelled else { - await connection.close(errorCode: 1, reason: "stale_generation") - return - } - guard pendingAdmissions.count < maximumPendingAdmissions else { - await connection.close(errorCode: 1, reason: "admission_capacity") - return - } - let pendingForIdentity = pendingAdmissions.values.lazy.filter { - $0.remoteIdentity == remoteIdentity - }.count - guard pendingForIdentity < maximumPendingAdmissionsPerIdentity else { - await connection.close( - errorCode: 1, - reason: "admission_identity_capacity" - ) - return - } - let activeForIdentity = activeConnections.values.lazy.filter { - $0.remoteIdentity == remoteIdentity - }.count - let isSameIdentityReplacement = pendingForIdentity == 0 && activeForIdentity > 0 - guard pendingAdmissions.count + activeConnections.count < maximumConnections - || isSameIdentityReplacement else { - await connection.close(errorCode: 1, reason: "connection_capacity") - return - } - guard pendingForIdentity + activeForIdentity < maximumConnectionsPerIdentity - || isSameIdentityReplacement else { - await connection.close( - errorCode: 1, - reason: "connection_identity_capacity" - ) - return - } - let id = UUID() - let handler = handler - let handlerTask = Task { [weak self] in - do { - try await handler(connection, generation) { [weak self] in - await self?.markAdmitted(id, generation: generation) ?? false - } - await self?.finishHandler(id, error: nil) - } catch { - await self?.finishHandler(id, error: error) - } - } - let clock = clock - let deadline = clock.now().addingTimeInterval(admissionTimeout) - let deadlineTask = Task { [weak self] in - do { - try await clock.sleep(until: deadline) - try Task.checkCancellation() - await self?.timeOutAdmission(id) - } catch {} - } - pendingAdmissions[id] = PendingAdmission( - generation: generation, - remoteIdentity: remoteIdentity, - connection: connection, - handlerTask: handlerTask, - deadlineTask: deadlineTask - ) - } - - private func markAdmitted(_ id: UUID, generation: UInt64) async -> Bool { - guard currentGeneration == generation, - let admission = pendingAdmissions.removeValue(forKey: id), - admission.generation == generation else { - return false - } - admission.deadlineTask.cancel() - - // One endpoint identity represents one installed client identity. A - // newly authenticated connection from that identity is therefore the - // authoritative replacement for older connections that may still look - // alive after the client was force-quit, crashed, or changed networks. - // Wait until admission succeeds before evicting them so an unauthenticated - // or failed reconnect cannot disrupt a healthy session. - let superseded = activeConnections.filter { _, connection in - connection.generation == generation - && connection.remoteIdentity == admission.remoteIdentity - } - for supersededID in superseded.keys { - activeConnections[supersededID] = nil - } - activeConnections[id] = ActiveConnection( - generation: generation, - remoteIdentity: admission.remoteIdentity, - connection: admission.connection, - handlerTask: admission.handlerTask - ) - for connection in superseded.values { - connection.handlerTask.cancel() - await connection.connection.close( - errorCode: 0, - reason: "superseded_connection" - ) - } - return true - } - - private func finishHandler(_ id: UUID, error: (any Error)?) async { - if let admission = pendingAdmissions.removeValue(forKey: id) { - admission.deadlineTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: error == nil ? "admission_incomplete" : "admission_failed" - ) - return - } - guard let active = activeConnections.removeValue(forKey: id) else { - return - } - if error != nil { - await active.connection.close( - errorCode: 1, - reason: "connection_failed" - ) - } - } - - private func timeOutAdmission(_ id: UUID) async { - guard let admission = pendingAdmissions.removeValue(forKey: id) else { - return - } - admission.handlerTask.cancel() - await admission.connection.close( - errorCode: 1, - reason: "admission_timeout" - ) - } - - private func cancelConnections( - exceptGeneration retainedGeneration: UInt64?, - reason: String - ) async { - let stale = pendingAdmissions.filter { _, admission in - admission.generation != retainedGeneration - } - for id in stale.keys { pendingAdmissions[id] = nil } - for admission in stale.values { - admission.handlerTask.cancel() - admission.deadlineTask.cancel() - await admission.connection.close(errorCode: 1, reason: reason) - } - let active = activeConnections.filter { _, connection in - connection.generation != retainedGeneration - } - for id in active.keys { activeConnections[id] = nil } - for connection in active.values { - connection.handlerTask.cancel() - await connection.connection.close(errorCode: 1, reason: reason) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSnapshot.swift deleted file mode 100644 index 87a0708b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSnapshot.swift +++ /dev/null @@ -1,29 +0,0 @@ -public import CMUXMobileCore - -/// A non-sensitive snapshot of the endpoint supervisor state. -public struct CmxIrohEndpointSnapshot: Equatable, Sendable { - /// The endpoint-instance generation, incremented for every bind attempt. - public let runtimeGeneration: UInt64 - - /// The current lifecycle state. - public let state: CmxIrohEndpointLifecycleState - - /// The stable identity when an endpoint is active. - public let identity: CmxIrohPeerIdentity? - - /// Creates a supervisor snapshot. - /// - /// - Parameters: - /// - runtimeGeneration: The endpoint-instance generation. - /// - state: The current lifecycle state. - /// - identity: The active stable EndpointID, if available. - public init( - runtimeGeneration: UInt64, - state: CmxIrohEndpointLifecycleState, - identity: CmxIrohPeerIdentity? - ) { - self.runtimeGeneration = runtimeGeneration - self.state = state - self.identity = identity - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift deleted file mode 100644 index a8ce2875..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift +++ /dev/null @@ -1,594 +0,0 @@ -public import CMUXMobileCore -import Foundation - -/// Owns Iroh endpoint generations and recreates unexpectedly stopped drivers. -/// -/// The actor preserves the injected secret key across foreground recreation, -/// rejects stale async bind results with a lifecycle revision, and exposes -/// non-sensitive state through ``events()``. -public actor CmxIrohEndpointSupervisor { - private struct RelayReadinessWaiter { - let generation: UInt64 - let continuation: CheckedContinuation<Void, any Error> - } - - private let factory: any CmxIrohEndpointFactory - private var configuration: CmxIrohEndpointConfiguration - private var endpoint: (any CmxIrohEndpoint)? - private var bindingOperation: ( - revision: UInt64, - generation: UInt64, - task: Task<any CmxIrohEndpoint, any Error> - )? - private var healthTask: Task<Void, Never>? - private var runtimeGeneration: UInt64 = 0 - private var lifecycleRevision: UInt64 = 0 - private var desiredActive = false - private var snapshot = CmxIrohEndpointSnapshot( - runtimeGeneration: 0, - state: .inactive, - identity: nil - ) - private var observers: [UUID: AsyncStream<CmxIrohEndpointSupervisorEvent>.Continuation] = [:] - /// The exact active generation for which native Iroh reported a usable - /// home relay, or whose public address already contains a usable relay - /// hint. No relay URL or credential leaves this actor through this state. - private var relayReadyGeneration: UInt64? - private var relayReadinessWaiters: [UUID: RelayReadinessWaiter] = [:] - - /// Creates an inactive endpoint supervisor. - /// - /// - Parameters: - /// - factory: The concrete Iroh binding seam. - /// - configuration: The stable key, ALPN, relay allowlist, and current tokens. - public init( - factory: any CmxIrohEndpointFactory, - configuration: CmxIrohEndpointConfiguration - ) { - self.factory = factory - self.configuration = configuration - } - - /// Returns an event stream beginning with the current lifecycle snapshot. - /// - /// - Returns: A stream that finishes when its consumer cancels observation. - public func events() -> AsyncStream<CmxIrohEndpointSupervisorEvent> { - let observerID = UUID() - let initialSnapshot = snapshot - return AsyncStream { continuation in - observers[observerID] = continuation - continuation.yield(.snapshot(initialSnapshot)) - continuation.onTermination = { [weak self] _ in - Task { await self?.removeObserver(observerID) } - } - } - } - - /// Binds an endpoint if the lifecycle is not already active. - /// - /// - Returns: The active generation snapshot. - /// - Throws: A bind error, `CancellationError`, or - /// ``CmxIrohEndpointSupervisorError/superseded``. - @discardableResult - public func activate() async throws -> CmxIrohEndpointSnapshot { - try Task.checkCancellation() - desiredActive = true - if endpoint != nil, snapshot.state == .active { - return snapshot - } - - let operation: ( - revision: UInt64, - generation: UInt64, - task: Task<any CmxIrohEndpoint, any Error> - ) - if let bindingOperation { - operation = bindingOperation - } else { - lifecycleRevision &+= 1 - runtimeGeneration &+= 1 - invalidateRelayReadiness( - error: CmxIrohEndpointSupervisorError.superseded - ) - let revision = lifecycleRevision - let generation = runtimeGeneration - let factory = factory - let configuration = configuration - let task = Task<any CmxIrohEndpoint, any Error> { - let candidate = try await factory.bind(configuration: configuration) - guard !Task.isCancelled else { - await candidate.close() - throw CancellationError() - } - return candidate - } - operation = (revision, generation, task) - bindingOperation = operation - publishSnapshot( - CmxIrohEndpointSnapshot( - runtimeGeneration: generation, - state: .starting, - identity: nil - ) - ) - } - - do { - let candidate = try await operation.task.value - if endpoint != nil, - snapshot.state == .active, - snapshot.runtimeGeneration == operation.generation { - return snapshot - } - guard desiredActive, lifecycleRevision == operation.revision else { - await candidate.close() - throw CmxIrohEndpointSupervisorError.superseded - } - let identity = await candidate.identity() - guard desiredActive, lifecycleRevision == operation.revision else { - await candidate.close() - throw CmxIrohEndpointSupervisorError.superseded - } - endpoint = candidate - bindingOperation = nil - publishSnapshot( - CmxIrohEndpointSnapshot( - runtimeGeneration: operation.generation, - state: .active, - identity: identity - ) - ) - observeHealth(of: candidate, generation: operation.generation) - return snapshot - } catch { - if bindingOperation?.revision == operation.revision { - bindingOperation = nil - } - if lifecycleRevision == operation.revision, endpoint == nil { - publishSnapshot( - CmxIrohEndpointSnapshot( - runtimeGeneration: operation.generation, - state: desiredActive ? .failed : .inactive, - identity: nil - ) - ) - } - throw error - } - } - - /// Closes the active endpoint and invalidates all generation-owned work. - public func deactivate() async { - desiredActive = false - lifecycleRevision &+= 1 - invalidateRelayReadiness(error: CmxIrohEndpointSupervisorError.inactive) - bindingOperation?.task.cancel() - bindingOperation = nil - healthTask?.cancel() - healthTask = nil - let closingEndpoint = endpoint - endpoint = nil - publishSnapshot( - CmxIrohEndpointSnapshot( - runtimeGeneration: runtimeGeneration, - state: .inactive, - identity: nil - ) - ) - await closingEndpoint?.close() - } - - /// Returns the active endpoint for a generation-scoped operation. - /// - /// - Returns: The active endpoint existential. - /// - Throws: ``CmxIrohEndpointSupervisorError/inactive`` when unbound. - public func activeEndpoint() throws -> any CmxIrohEndpoint { - guard let endpoint, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.inactive - } - return endpoint - } - - /// Returns whether the active endpoint has a credential-free usable home - /// relay signal for its current generation. - /// - /// The native `online` signal is authoritative. A current public relay hint - /// is also sufficient because it can only be produced from the active - /// endpoint's exact relay allowlist. - public func hasUsableHomeRelay() async -> Bool { - guard let endpoint, snapshot.state == .active else { return false } - let generation = snapshot.runtimeGeneration - if relayReadyGeneration == generation { return true } - guard await Self.hasUsableRelayHint(endpoint) else { return false } - markRelayReady(generation: generation) - return true - } - - /// Returns whether the active generation owns at least one configured - /// relay. This exposes no URL or credential material. - func hasConfiguredRelay() -> Bool { - endpoint != nil - && snapshot.state == .active - && !configuration.relayProfile.activeRelays.isEmpty - } - - /// Waits for native Iroh to establish a usable home relay without polling. - /// - /// The wait is scoped to the current endpoint generation, races a health - /// signal against one cancellable deadline, and fails if lifecycle changes - /// replace the endpoint while the caller is suspended. - public func waitForUsableHomeRelay( - timeout: Duration = .seconds(15) - ) async throws { - guard timeout > .zero, - endpoint != nil, - snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.inactive - } - let generation = snapshot.runtimeGeneration - if await hasUsableHomeRelay() { return } - - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { [weak self] in - guard let self else { - throw CmxIrohEndpointSupervisorError.inactive - } - try await self.awaitRelayReadiness(generation: generation) - } - group.addTask { - try await ContinuousClock().sleep(for: timeout) - throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut - } - defer { group.cancelAll() } - guard let result = try await group.next() else { - throw CmxIrohEndpointSupervisorError.inactive - } - _ = result - } - - try Task.checkCancellation() - guard snapshot.state == .active, - snapshot.runtimeGeneration == generation, - relayReadyGeneration == generation else { - throw CmxIrohEndpointSupervisorError.superseded - } - } - - /// Verifies the live driver after app suspension and recreates it when stale. - /// - /// Healthy generations remain untouched, preserving every open QUIC - /// connection and stream across ordinary background transitions. - /// - /// - Returns: The current or replacement active generation snapshot. - /// - Throws: The replacement bind error when the stale generation cannot recover. - @discardableResult - public func ensureHealthy() async throws -> CmxIrohEndpointSnapshot { - guard desiredActive else { - throw CmxIrohEndpointSupervisorError.inactive - } - if let endpoint, snapshot.state == .active, await endpoint.isHealthy() { - return snapshot - } - - lifecycleRevision &+= 1 - invalidateRelayReadiness( - error: CmxIrohEndpointSupervisorError.superseded - ) - bindingOperation?.task.cancel() - bindingOperation = nil - healthTask?.cancel() - healthTask = nil - let staleEndpoint = endpoint - self.endpoint = nil - await staleEndpoint?.close() - return try await activate() - } - - /// Installs a fresh relay set on the live endpoint before committing it for future binds. - /// - /// The concrete endpoint must add replacement credentials before removing - /// stale credentials. A failed update leaves this supervisor's last-known - /// good configuration unchanged. - /// - /// - Parameter relays: The complete new relay credential set. - /// - Throws: A fleet validation or endpoint update error. - public func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - try await replaceRelays( - relays, - expectedIdentity: Optional<CmxIrohPeerIdentity>.none - ) - } - - /// Installs relay credentials only on the active endpoint identity that requested them. - /// - /// A lifecycle transition during the update leaves the next generation's - /// configuration unchanged. This prevents a delayed token response for an - /// old binding from being committed to a replacement endpoint. - public func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity - ) async throws { - try await replaceRelays(relays, expectedIdentity: Optional(expectedIdentity)) - } - - private func replaceRelays( - _ relays: [CmxIrohRelayConfiguration], - expectedIdentity: CmxIrohPeerIdentity? - ) async throws { - let candidateProfile = try configuration.relayProfile.replacingManagedRelays(relays) - let candidateConfiguration = CmxIrohEndpointConfiguration( - secretKey: configuration.secretKey, - alpns: configuration.alpns, - bindPolicy: configuration.bindPolicy, - relayProfile: candidateProfile - ) - guard let endpoint else { - guard expectedIdentity == nil else { - throw CmxIrohEndpointSupervisorError.inactive - } - configuration = candidateConfiguration - return - } - let revision = lifecycleRevision - if let expectedIdentity { - let actualIdentity = await endpoint.identity() - guard lifecycleRevision == revision, - snapshot.state == .active, - actualIdentity == expectedIdentity else { - throw CmxIrohEndpointSupervisorError.superseded - } - } - let previousAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - let priorRelayReadyGeneration = relayReadyGeneration - relayReadyGeneration = nil - do { - try await endpoint.replaceRelays(relays) - } catch { - if lifecycleRevision == revision, - snapshot.state == .active, - priorRelayReadyGeneration == snapshot.runtimeGeneration { - markRelayReady(generation: snapshot.runtimeGeneration) - } - throw error - } - let updatedAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - configuration = candidateConfiguration - if Self.hasUsableRelayHint(updatedAddress) { - markRelayReady(generation: snapshot.runtimeGeneration) - } - // The endpoint's address watcher may observe the new home relay while - // `replaceRelays` is suspended, before the endpoint commits the matching - // allowlist. That early event is filtered by the old profile and may be - // the only native address callback. Republish after both endpoint and - // supervisor configuration commit so owners re-read one coherent route. - if updatedAddress != previousAddress { - publish(.networkChanged(runtimeGeneration: snapshot.runtimeGeneration)) - } - } - - /// Installs a complete managed selection or custom relay override live. - /// - /// The endpoint keeps its stable key and adds replacement relays before it - /// removes stale relays. A failed update leaves the supervisor's future bind - /// configuration unchanged. - /// - /// - Parameter profile: Exact relay allowlist and active configurations. - public func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile - ) async throws { - try await replaceRelayProfile( - profile, - expectedIdentity: Optional<CmxIrohPeerIdentity>.none - ) - } - - /// Installs a profile only on the active endpoint identity that requested it. - public func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile, - expectedIdentity: CmxIrohPeerIdentity - ) async throws { - try await replaceRelayProfile(profile, expectedIdentity: Optional(expectedIdentity)) - } - - private func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile, - expectedIdentity: CmxIrohPeerIdentity? - ) async throws { - let candidateConfiguration = CmxIrohEndpointConfiguration( - secretKey: configuration.secretKey, - alpns: configuration.alpns, - bindPolicy: configuration.bindPolicy, - relayProfile: profile - ) - guard let endpoint else { - guard expectedIdentity == nil else { - throw CmxIrohEndpointSupervisorError.inactive - } - configuration = candidateConfiguration - return - } - let revision = lifecycleRevision - if let expectedIdentity { - let actualIdentity = await endpoint.identity() - guard lifecycleRevision == revision, - snapshot.state == .active, - actualIdentity == expectedIdentity else { - throw CmxIrohEndpointSupervisorError.superseded - } - } - let previousAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - let priorRelayReadyGeneration = relayReadyGeneration - relayReadyGeneration = nil - do { - try await endpoint.replaceRelayProfile(profile) - } catch { - if lifecycleRevision == revision, - snapshot.state == .active, - priorRelayReadyGeneration == snapshot.runtimeGeneration { - markRelayReady(generation: snapshot.runtimeGeneration) - } - throw error - } - let updatedAddress = await endpoint.address() - guard lifecycleRevision == revision, snapshot.state == .active else { - throw CmxIrohEndpointSupervisorError.superseded - } - configuration = candidateConfiguration - if Self.hasUsableRelayHint(updatedAddress) { - markRelayReady(generation: snapshot.runtimeGeneration) - } - if updatedAddress != previousAddress { - publish(.networkChanged(runtimeGeneration: snapshot.runtimeGeneration)) - } - } - - private func observeHealth( - of endpoint: any CmxIrohEndpoint, - generation: UInt64 - ) { - healthTask?.cancel() - healthTask = Task { [weak self] in - let events = await endpoint.healthEvents() - for await event in events { - guard !Task.isCancelled else { return } - await self?.handleHealthEvent(event, generation: generation) - } - } - } - - private func handleHealthEvent( - _ event: CmxIrohEndpointHealthEvent, - generation: UInt64 - ) async { - guard desiredActive, - generation == runtimeGeneration, - snapshot.state == .active else { - return - } - switch event { - case .online: - markRelayReady(generation: generation) - // Initial discovery can finish before or after the runtime registers. - // Treat online as a reachability change so the broker receives the - // endpoint's first usable relay or direct-address hints. - publish(.networkChanged(runtimeGeneration: generation)) - case .networkChanged: - if let endpoint, - await Self.hasUsableRelayHint(endpoint) { - markRelayReady(generation: generation) - } - publish(.networkChanged(runtimeGeneration: generation)) - case .closedUnexpectedly: - let previousGeneration = generation - endpoint = nil - healthTask = nil - do { - let recovered = try await activate() - publish( - .recovered( - previousGeneration: previousGeneration, - newGeneration: recovered.runtimeGeneration - ) - ) - } catch { - // `activate()` publishes the failed snapshot. The next explicit - // lifecycle activation can retry without reusing stale handles. - } - } - } - - private func publishSnapshot(_ newSnapshot: CmxIrohEndpointSnapshot) { - snapshot = newSnapshot - publish(.snapshot(newSnapshot)) - } - - private func awaitRelayReadiness(generation: UInt64) async throws { - let id = UUID() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation<Void, any Error>) in - guard !Task.isCancelled else { - continuation.resume(throwing: CancellationError()) - return - } - guard snapshot.state == .active, - snapshot.runtimeGeneration == generation else { - continuation.resume( - throwing: CmxIrohEndpointSupervisorError.superseded - ) - return - } - if relayReadyGeneration == generation { - continuation.resume() - } else { - relayReadinessWaiters[id] = RelayReadinessWaiter( - generation: generation, - continuation: continuation - ) - } - } - } onCancel: { - Task { await self.cancelRelayReadinessWaiter(id: id) } - } - } - - private func cancelRelayReadinessWaiter(id: UUID) { - relayReadinessWaiters.removeValue(forKey: id)? - .continuation.resume(throwing: CancellationError()) - } - - private func markRelayReady(generation: UInt64) { - guard generation > 0, - snapshot.state == .active, - snapshot.runtimeGeneration == generation else { return } - relayReadyGeneration = generation - let ready = relayReadinessWaiters.filter { $0.value.generation == generation } - for (id, waiter) in ready { - relayReadinessWaiters[id] = nil - waiter.continuation.resume() - } - } - - private func invalidateRelayReadiness(error: any Error) { - relayReadyGeneration = nil - let waiters = relayReadinessWaiters.values - relayReadinessWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.continuation.resume(throwing: error) - } - } - - private static func hasUsableRelayHint( - _ endpoint: any CmxIrohEndpoint - ) async -> Bool { - hasUsableRelayHint(await endpoint.address()) - } - - private static func hasUsableRelayHint( - _ address: CmxIrohEndpointAddress - ) -> Bool { - let current = Date() - return address.pathHints.contains { - $0.kind == .relayURL && $0.isUsable(at: current) - } - } - - private func publish(_ event: CmxIrohEndpointSupervisorEvent) { - for continuation in observers.values { - continuation.yield(event) - } - } - - private func removeObserver(_ observerID: UUID) { - observers.removeValue(forKey: observerID) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisorError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisorError.swift deleted file mode 100644 index 5f0b676d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisorError.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// Lifecycle failures surfaced by ``CmxIrohEndpointSupervisor``. -public enum CmxIrohEndpointSupervisorError: Error, Equatable, Sendable { - /// No active endpoint is available for a dial or accept operation. - case inactive - - /// A newer lifecycle transition invalidated an in-flight bind result. - case superseded - - /// The active endpoint did not establish a usable home relay before the - /// caller's bounded readiness deadline. - case relayReadinessTimedOut -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisorEvent.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisorEvent.swift deleted file mode 100644 index e32c682e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisorEvent.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// An observable state or reachability event from the endpoint supervisor. -public enum CmxIrohEndpointSupervisorEvent: Equatable, Sendable { - /// The endpoint lifecycle snapshot changed. - case snapshot(CmxIrohEndpointSnapshot) - - /// Iroh observed a local network change for the active generation. - case networkChanged(runtimeGeneration: UInt64) - - /// An unexpectedly closed driver was replaced using the same secret key. - case recovered(previousGeneration: UInt64, newGeneration: UInt64) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantClaims.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantClaims.swift deleted file mode 100644 index 3b0e4b96..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantClaims.swift +++ /dev/null @@ -1,170 +0,0 @@ -public import CMUXMobileCore -import Foundation - -/// Exact endpoint tuple signed into one side of a pair grant. -public struct CmxIrohGrantPeer: Decodable, Equatable, Sendable { - public let bindingID: String - public let deviceID: String - public let tag: String - public let platform: CmxIrohPlatform - public let endpointID: CmxIrohPeerIdentity - public let identityGeneration: Int - - private enum CodingKeys: String, CodingKey { - case bindingID = "bindingId" - case deviceID = "deviceId" - case tag - case platform - case endpointID = "endpointId" - case identityGeneration - } - - public init(binding: CmxIrohBrokerBinding) { - bindingID = binding.bindingID - deviceID = binding.deviceID - tag = binding.tag - platform = binding.platform - endpointID = binding.endpointID - identityGeneration = binding.identityGeneration - } - - public init( - bindingID: String, - deviceID: String, - tag: String, - platform: CmxIrohPlatform, - endpointID: CmxIrohPeerIdentity, - identityGeneration: Int - ) { - self.bindingID = bindingID - self.deviceID = deviceID - self.tag = tag - self.platform = platform - self.endpointID = endpointID - self.identityGeneration = identityGeneration - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - bindingID = try container.decode(String.self, forKey: .bindingID) - deviceID = try container.decode(String.self, forKey: .deviceID) - tag = try container.decode(String.self, forKey: .tag) - platform = try container.decode(CmxIrohPlatform.self, forKey: .platform) - endpointID = try CmxIrohPeerIdentity( - endpointID: container.decode(String.self, forKey: .endpointID) - ) - identityGeneration = try container.decode(Int.self, forKey: .identityGeneration) - } -} - -/// Verified authorization claims for one iOS-to-Mac app session. -public struct CmxIrohPairGrantClaims: Decodable, Equatable, Sendable { - public let grantID: String - public let issuedAt: Int64 - public let notBefore: Int64 - public let expiresAt: Int64 - public let alpn: String - public let scope: String - public let initiator: CmxIrohGrantPeer - public let acceptor: CmxIrohGrantPeer - - private enum CodingKeys: String, CodingKey { - case grantID = "jti" - case issuedAt = "iat" - case notBefore = "nbf" - case expiresAt = "exp" - case alpn - case scope - case initiator - case acceptor - } -} - -/// Exact endpoint tuple expected in a cached endpoint attestation. -public struct CmxIrohEndpointExpectation: Equatable, Sendable { - public let bindingID: String - public let deviceID: String - public let endpointID: CmxIrohPeerIdentity - public let identityGeneration: Int - public let platform: CmxIrohPlatform - - public init(binding: CmxIrohBrokerBinding) { - bindingID = binding.bindingID - deviceID = binding.deviceID - endpointID = binding.endpointID - identityGeneration = binding.identityGeneration - platform = binding.platform - } - - public init( - bindingID: String, - deviceID: String, - endpointID: CmxIrohPeerIdentity, - identityGeneration: Int, - platform: CmxIrohPlatform - ) { - self.bindingID = bindingID - self.deviceID = deviceID - self.endpointID = endpointID - self.identityGeneration = identityGeneration - self.platform = platform - } -} - -/// Verified one-day same-account proof for an endpoint binding. -public struct CmxIrohEndpointAttestationClaims: Decodable, Equatable, Sendable { - public let version: Int - public let attestationID: String - public let accountSubject: String - public let bindingID: String - public let deviceID: String - public let endpointID: CmxIrohPeerIdentity - public let identityGeneration: Int - public let platform: CmxIrohPlatform - public let issuedAt: Int64 - public let notBefore: Int64 - public let expiresAt: Int64 - public let alpn: String - public let scope: String - - private enum CodingKeys: String, CodingKey { - case version - case attestationID = "jti" - case accountSubject = "sub" - case bindingID = "bindingId" - case deviceID = "deviceId" - case endpointID = "endpointId" - case identityGeneration - case platform - case issuedAt = "iat" - case notBefore = "nbf" - case expiresAt = "exp" - case alpn - case scope - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - version = try container.decode(Int.self, forKey: .version) - attestationID = try container.decode(String.self, forKey: .attestationID) - accountSubject = try container.decode(String.self, forKey: .accountSubject) - bindingID = try container.decode(String.self, forKey: .bindingID) - deviceID = try container.decode(String.self, forKey: .deviceID) - endpointID = try CmxIrohPeerIdentity( - endpointID: container.decode(String.self, forKey: .endpointID) - ) - identityGeneration = try container.decode(Int.self, forKey: .identityGeneration) - platform = try container.decode(CmxIrohPlatform.self, forKey: .platform) - issuedAt = try container.decode(Int64.self, forKey: .issuedAt) - notBefore = try container.decode(Int64.self, forKey: .notBefore) - expiresAt = try container.decode(Int64.self, forKey: .expiresAt) - alpn = try container.decode(String.self, forKey: .alpn) - scope = try container.decode(String.self, forKey: .scope) - } -} - -/// Both verified attestations from an offline same-account pairing attempt. -public struct CmxIrohVerifiedOfflinePair: Equatable, Sendable { - public let initiator: CmxIrohEndpointAttestationClaims - public let acceptor: CmxIrohEndpointAttestationClaims -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantVerifier.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantVerifier.swift deleted file mode 100644 index 348dc141..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantVerifier.swift +++ /dev/null @@ -1,376 +0,0 @@ -import CryptoKit -public import CMUXMobileCore -public import Foundation - -/// Verifies broker Ed25519 credentials before they can authorize an Iroh stream. -public struct CmxIrohGrantVerifier: Sendable { - private struct Header: Decodable { - let alg: String - let typ: String - let kid: String - } - - private static let pairType = "cmux-pair-grant+jwt" - private static let attestationType = "cmux-endpoint-attestation-v1+jwt" - private static let alpn = "cmux/mobile/1" - private static let pairScope = "cmux.mobile.attach" - private static let attestationScope = "cmux.offline-pair.same-account" - private static let pairLifetime: Int64 = 7 * 24 * 60 * 60 - private static let attestationLifetime: Int64 = 24 * 60 * 60 - private static let ed25519SPKIPrefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, - 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]) - - public init() {} - - /// Verifies signature, claim shape, time window, platform direction, and both peers. - public func verifyPairGrant( - _ token: String, - keys: CmxIrohGrantVerificationKeySet, - initiator: CmxIrohGrantPeer, - acceptor: CmxIrohGrantPeer, - now: Date - ) throws -> CmxIrohPairGrantClaims { - let claims = try verifiedPairClaims(token, keys: keys, now: now) - guard claims.initiator == initiator, claims.acceptor == acceptor else { - throw CmxIrohGrantVerifierError.identityMismatch - } - return claims - } - - /// Verifies a grant against the TLS initiator and the Mac's exact local binding. - public func verifyPairGrant( - _ token: String, - keys: CmxIrohGrantVerificationKeySet, - authenticatedInitiatorID: CmxIrohPeerIdentity, - acceptor: CmxIrohGrantPeer, - now: Date - ) throws -> CmxIrohPairGrantClaims { - let claims = try verifiedPairClaims(token, keys: keys, now: now) - guard claims.initiator.endpointID == authenticatedInitiatorID, - claims.initiator.platform == .ios, - claims.acceptor == acceptor else { - throw CmxIrohGrantVerifierError.identityMismatch - } - return claims - } - - private func verifiedPairClaims( - _ token: String, - keys: CmxIrohGrantVerificationKeySet, - now: Date - ) throws -> CmxIrohPairGrantClaims { - let payload = try verifiedPayload(token, type: Self.pairType, keys: keys) - try Self.requireExactKeys( - payload, - keys: ["jti", "iat", "nbf", "exp", "alpn", "scope", "initiator", "acceptor"] - ) - let claims: CmxIrohPairGrantClaims - do { - claims = try JSONDecoder().decode(CmxIrohPairGrantClaims.self, from: payload) - } catch { - throw CmxIrohGrantVerifierError.invalidClaims - } - let nowSeconds = try Self.seconds(now) - let futureTolerance = try Self.sum(nowSeconds, 30) - let lifetime = try Self.difference(claims.expiresAt, claims.issuedAt) - guard Self.isCanonicalUUID(claims.grantID), - claims.alpn == Self.alpn, - claims.scope == Self.pairScope, - claims.notBefore <= futureTolerance, - claims.expiresAt > claims.notBefore, - lifetime <= Self.pairLifetime, - claims.issuedAt <= futureTolerance, - Self.validPeer(claims.initiator), - Self.validPeer(claims.acceptor), - claims.initiator.platform == .ios, - claims.acceptor.platform == .mac else { - throw CmxIrohGrantVerifierError.invalidClaims - } - guard claims.expiresAt > nowSeconds else { - throw CmxIrohGrantVerifierError.expired - } - return claims - } - - /// Verifies one cached endpoint attestation against an exact local binding tuple. - public func verifyEndpointAttestation( - _ token: String, - keys: CmxIrohGrantVerificationKeySet, - expected: CmxIrohEndpointExpectation, - now: Date - ) throws -> CmxIrohEndpointAttestationClaims { - let claims = try verifiedEndpointClaims(token, keys: keys, now: now) - guard claims.bindingID == expected.bindingID, - claims.deviceID == expected.deviceID, - claims.endpointID == expected.endpointID, - claims.identityGeneration == expected.identityGeneration, - claims.platform == expected.platform else { - throw CmxIrohGrantVerifierError.identityMismatch - } - return claims - } - - /// Verifies a peer attestation when the signed tuple is authoritative and TLS pins its EndpointID. - public func verifyEndpointAttestation( - _ token: String, - keys: CmxIrohGrantVerificationKeySet, - authenticatedEndpointID: CmxIrohPeerIdentity, - requiredPlatform: CmxIrohPlatform, - now: Date - ) throws -> CmxIrohEndpointAttestationClaims { - let claims = try verifiedEndpointClaims(token, keys: keys, now: now) - guard claims.endpointID == authenticatedEndpointID, - claims.platform == requiredPlatform else { - throw CmxIrohGrantVerifierError.identityMismatch - } - return claims - } - - private func verifiedEndpointClaims( - _ token: String, - keys: CmxIrohGrantVerificationKeySet, - now: Date - ) throws -> CmxIrohEndpointAttestationClaims { - let payload = try verifiedPayload(token, type: Self.attestationType, keys: keys) - try Self.requireExactKeys( - payload, - keys: [ - "version", "jti", "sub", "bindingId", "deviceId", "endpointId", - "identityGeneration", "platform", "iat", "nbf", "exp", "alpn", "scope", - ] - ) - let claims: CmxIrohEndpointAttestationClaims - do { - claims = try JSONDecoder().decode(CmxIrohEndpointAttestationClaims.self, from: payload) - } catch { - throw CmxIrohGrantVerifierError.invalidClaims - } - let nowSeconds = try Self.seconds(now) - let futureTolerance = try Self.sum(nowSeconds, 30) - let notBeforeFloor = try Self.difference(claims.issuedAt, 30) - let lifetime = try Self.difference(claims.expiresAt, claims.issuedAt) - guard claims.version == 1, - Self.isCanonicalUUID(claims.attestationID), - Self.isCanonicalUUID(claims.bindingID), - Self.isCanonicalUUID(claims.deviceID), - Self.decodeBase64URL(claims.accountSubject)?.count == 32, - (1 ... Int(Int32.max)).contains(claims.identityGeneration), - claims.alpn == Self.alpn, - claims.scope == Self.attestationScope, - claims.notBefore >= notBeforeFloor, - claims.notBefore <= futureTolerance, - claims.expiresAt > claims.notBefore, - lifetime <= Self.attestationLifetime, - claims.issuedAt <= futureTolerance else { - throw CmxIrohGrantVerifierError.invalidClaims - } - guard claims.expiresAt > nowSeconds else { - throw CmxIrohGrantVerifierError.expired - } - return claims - } - - /// Verifies both offline attestations and their same-account relationship. - public func verifyOfflineSameAccountPair( - initiatorToken: String, - acceptorToken: String, - keys: CmxIrohGrantVerificationKeySet, - initiator: CmxIrohEndpointExpectation, - acceptor: CmxIrohEndpointExpectation, - now: Date - ) throws -> CmxIrohVerifiedOfflinePair { - guard initiator.platform == .ios, acceptor.platform == .mac else { - throw CmxIrohGrantVerifierError.invalidClaims - } - let initiatorClaims = try verifyEndpointAttestation( - initiatorToken, - keys: keys, - expected: initiator, - now: now - ) - let acceptorClaims = try verifyEndpointAttestation( - acceptorToken, - keys: keys, - expected: acceptor, - now: now - ) - guard initiatorClaims.bindingID != acceptorClaims.bindingID, - initiatorClaims.deviceID != acceptorClaims.deviceID, - initiatorClaims.endpointID != acceptorClaims.endpointID, - let left = Self.decodeBase64URL(initiatorClaims.accountSubject), - let right = Self.decodeBase64URL(acceptorClaims.accountSubject), - Self.constantTimeEqual(left, right) else { - throw CmxIrohGrantVerifierError.accountMismatch - } - return CmxIrohVerifiedOfflinePair( - initiator: initiatorClaims, - acceptor: acceptorClaims - ) - } - - private func verifiedPayload( - _ token: String, - type: String, - keys: CmxIrohGrantVerificationKeySet - ) throws -> Data { - guard (5 ... 16 * 1_024).contains(token.utf8.count) else { - throw CmxIrohGrantVerifierError.invalidToken - } - let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count == 3, - let headerData = Self.decodeBase64URL(String(segments[0])), - let payload = Self.decodeBase64URL(String(segments[1])), - let signature = Self.decodeBase64URL(String(segments[2])), - signature.count == 64 else { - throw CmxIrohGrantVerifierError.invalidToken - } - try Self.requireExactKeys(headerData, keys: ["alg", "typ", "kid"]) - let header: Header - do { - header = try JSONDecoder().decode(Header.self, from: headerData) - } catch { - throw CmxIrohGrantVerifierError.invalidHeader - } - guard header.alg == "EdDSA", header.typ == type, Self.isSafeKeyID(header.kid) else { - throw CmxIrohGrantVerifierError.invalidHeader - } - let publicKey = try Self.publicKey(id: header.kid, keySet: keys) - let signingInput = Data("\(segments[0]).\(segments[1])".utf8) - guard publicKey.isValidSignature(signature, for: signingInput) else { - throw CmxIrohGrantVerifierError.invalidSignature - } - return payload - } - - private static func publicKey( - id: String, - keySet: CmxIrohGrantVerificationKeySet - ) throws -> Curve25519.Signing.PublicKey { - guard keySet.version == 1, - (1 ... 2).contains(keySet.keys.count), - isSafeKeyID(keySet.currentKeyID), - Set(keySet.keys.map(\.kid)).count == keySet.keys.count, - keySet.keys.contains(where: { $0.kid == keySet.currentKeyID }) else { - throw CmxIrohGrantVerifierError.invalidKeySet - } - for key in keySet.keys { - guard isSafeKeyID(key.kid), key.alg == "EdDSA", - let der = Data(base64Encoded: key.spkiDerBase64), - der.base64EncodedString() == key.spkiDerBase64, - der.count == ed25519SPKIPrefix.count + 32, - der.prefix(ed25519SPKIPrefix.count) == ed25519SPKIPrefix else { - throw CmxIrohGrantVerifierError.invalidKeySet - } - } - guard let selected = keySet.keys.first(where: { $0.kid == id }) else { - throw CmxIrohGrantVerifierError.unknownKeyID - } - let der = Data(base64Encoded: selected.spkiDerBase64)! - do { - return try Curve25519.Signing.PublicKey( - rawRepresentation: der.suffix(32) - ) - } catch { - throw CmxIrohGrantVerifierError.invalidKeySet - } - } - - private static func requireExactKeys(_ data: Data, keys: Set<String>) throws { - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - Set(object.keys) == keys else { - throw CmxIrohGrantVerifierError.invalidClaims - } - if keys.contains("initiator") { - let peerKeys: Set<String> = [ - "bindingId", "deviceId", "tag", "platform", "endpointId", "identityGeneration", - ] - guard let initiator = object["initiator"] as? [String: Any], - let acceptor = object["acceptor"] as? [String: Any], - Set(initiator.keys) == peerKeys, - Set(acceptor.keys) == peerKeys else { - throw CmxIrohGrantVerifierError.invalidClaims - } - } - } - - private static func validPeer(_ peer: CmxIrohGrantPeer) -> Bool { - isCanonicalUUID(peer.bindingID) - && isCanonicalUUID(peer.deviceID) - && (1 ... 64).contains(peer.tag.utf8.count) - && (1 ... Int(Int32.max)).contains(peer.identityGeneration) - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func isSafeKeyID(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } - - private static func decodeBase64URL(_ value: String) -> Data? { - guard !value.isEmpty, - value.utf8.allSatisfy({ byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || byte == 45 || byte == 95 - }) else { - return nil - } - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - let standard = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - guard let data = Data(base64Encoded: standard), base64URL(data) == value else { - return nil - } - return data - } - - private static func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - private static func seconds(_ date: Date) throws -> Int64 { - let value = date.timeIntervalSince1970 - guard value.isFinite, - value >= TimeInterval(Int64.min), - value <= TimeInterval(Int64.max) else { - throw CmxIrohGrantVerifierError.invalidClaims - } - return Int64(value.rounded(.down)) - } - - private static func sum(_ left: Int64, _ right: Int64) throws -> Int64 { - let result = left.addingReportingOverflow(right) - guard !result.overflow else { throw CmxIrohGrantVerifierError.invalidClaims } - return result.partialValue - } - - private static func difference(_ left: Int64, _ right: Int64) throws -> Int64 { - let result = left.subtractingReportingOverflow(right) - guard !result.overflow else { throw CmxIrohGrantVerifierError.invalidClaims } - return result.partialValue - } - - private static func constantTimeEqual(_ left: Data, _ right: Data) -> Bool { - guard left.count == right.count else { return false } - var difference: UInt8 = 0 - for (lhs, rhs) in zip(left, right) { - difference |= lhs ^ rhs - } - return difference == 0 - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantVerifierError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantVerifierError.swift deleted file mode 100644 index 615f9c7e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohGrantVerifierError.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// Fail-closed reasons for a backend-signed grant or attestation. -public enum CmxIrohGrantVerifierError: Error, Equatable, Sendable { - case invalidKeySet - case invalidToken - case invalidHeader - case unknownKeyID - case invalidSignature - case invalidClaims - case expired - case identityMismatch - case accountMismatch -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift deleted file mode 100644 index 5ab84238..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostBrokerServing.swift +++ /dev/null @@ -1,15 +0,0 @@ -/// Trust-broker operations required by a Mac host runtime. -public protocol CmxIrohHostBrokerServing: CmxIrohDiscoveryServing, - CmxIrohRelayTokenServing, CmxIrohBindingRevoking -{ - func register( - prepared: CmxIrohPreparedRegistration, - signer: CmxIrohRegistrationSigner - ) async throws -> CmxIrohRegistrationResponse - - func issueEndpointAttestation( - bindingID: String - ) async throws -> CmxIrohEndpointAttestationResponse -} - -extension CmxIrohTrustBrokerClient: CmxIrohHostBrokerServing {} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostLANAdvertisementContext.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostLANAdvertisementContext.swift deleted file mode 100644 index c95f6a03..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostLANAdvertisementContext.swift +++ /dev/null @@ -1,13 +0,0 @@ -/// Verified account-private material needed for local-link advertisement. -public struct CmxIrohHostLANAdvertisementContext: Equatable, Sendable { - public let binding: CmxIrohBrokerBindingMetadata - public let rendezvous: CmxIrohLANRendezvous - - public init( - binding: CmxIrohBrokerBindingMetadata, - rendezvous: CmxIrohLANRendezvous - ) { - self.binding = binding - self.rendezvous = rendezvous - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyCache.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyCache.swift deleted file mode 100644 index 19dec08c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyCache.swift +++ /dev/null @@ -1,254 +0,0 @@ -import CryptoKit -public import Foundation - -/// Stores one active account's cryptographically verified offline Mac host policy. -public actor CmxIrohHostPolicyCache { - private static let storageAccount = "active-host-policy" - - private let secureStore: any CmxIrohSecureCredentialStoring - private let verifier: CmxIrohGrantVerifier - private var lifecycleEpoch: UInt64 = 0 - private var deactivationCount = 0 - private var activeStorageMutationCount = 0 - private var storageMutationDrainWaiters: [CheckedContinuation<Void, Never>] = [] - - /// Creates a cache with injectable secure storage and signature verification. - /// - /// The production default uses a Keychain service distinct from relay - /// credentials with `AfterFirstUnlockThisDeviceOnly` data protection. - /// - /// - Parameters: - /// - secureStore: The secure persistence boundary for the single active policy. - /// - verifier: The broker Ed25519 grant and attestation verifier. - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( - service: "com.cmuxterm.iroh.host-policy.v1" - ), - verifier: CmxIrohGrantVerifier = CmxIrohGrantVerifier() - ) { - self.secureStore = secureStore - self.verifier = verifier - } - - /// Saves a policy only after verifying its signature, exact tuple, and expiry. - /// - /// A failed validation removes the active cache entry so a previously cached - /// policy cannot survive an identity or account transition. - /// - /// - Parameters: - /// - policy: The broker policy candidate to validate and persist. - /// - expectation: The current local account, identity, and host settings. - /// - now: The validation time. - /// - Throws: A policy, attestation, encoding, or secure-storage error. - public func save( - _ policy: CmxIrohCachedHostPolicy, - for expectation: CmxIrohHostPolicyExpectation, - now: Date - ) async throws { - let epoch = try beginOperation() - do { - try validate(policy, for: expectation, now: now) - } catch { - try await deleteSecureRecord(epoch: epoch) - throw error - } - let record = CmxIrohStoredHostPolicyRecord( - scopeDigest: Self.scopeDigest(for: expectation), - policy: policy - ) - let data = try JSONEncoder().encode(record) - try await writeSecureRecord( - data, - epoch: epoch - ) - } - - /// Loads a policy only when it still verifies for the current local state. - /// - /// Corrupt, expired, wrong-account, wrong-app-instance, wrong-generation, - /// wrong-keyset, and settings-mismatched entries are deleted and returned as - /// a cache miss. A verified result is an offline fallback only; callers must - /// replace it with fresh authenticated broker policy when online. - /// - /// - Parameters: - /// - expectation: The current local account, identity, and host settings. - /// - now: The validation time. - /// - Returns: The verified fallback policy, or `nil` when online registration is required. - /// - Throws: A secure-storage error when the invalid entry cannot be read or deleted. - public func load( - for expectation: CmxIrohHostPolicyExpectation, - now: Date - ) async throws -> CmxIrohCachedHostPolicy? { - let epoch = try beginOperation() - guard let data = try await readSecureRecord(epoch: epoch) else { - return nil - } - do { - let record = try JSONDecoder().decode( - CmxIrohStoredHostPolicyRecord.self, - from: data - ) - guard record.version == CmxIrohStoredHostPolicyRecord.currentVersion, - record.scopeDigest == Self.scopeDigest(for: expectation) else { - throw CmxIrohHostPolicyCacheError.policyMismatch - } - try validate(record.policy, for: expectation, now: now) - try requireCurrent(epoch) - return record.policy - } catch { - if error is CancellationError { throw error } - try await deleteSecureRecord(epoch: epoch) - return nil - } - } - - /// Deletes the active policy when it belongs to the supplied account scope. - /// - /// A corrupt envelope is also deleted because its ownership cannot be proven. - /// - /// - Parameter expectation: The current account and app-instance scope. - /// - Throws: A secure-storage error. - public func delete(for expectation: CmxIrohHostPolicyExpectation) async throws { - let epoch = try beginOperation() - guard let data = try await readSecureRecord(epoch: epoch) else { - return - } - guard let record = try? JSONDecoder().decode( - CmxIrohStoredHostPolicyRecord.self, - from: data - ) else { - try await deleteSecureRecord(epoch: epoch) - return - } - guard record.scopeDigest == Self.scopeDigest(for: expectation) else { - return - } - try await deleteSecureRecord(epoch: epoch) - } - - /// Removes every host-policy cache entry during sign-out or app-instance revocation. - /// - /// - Throws: A secure-storage error. - public func deactivate() async throws { - lifecycleEpoch &+= 1 - deactivationCount += 1 - defer { deactivationCount -= 1 } - await waitForStorageMutations() - try await secureStore.deleteAll() - } - - private func beginOperation() throws -> UInt64 { - guard deactivationCount == 0 else { throw CancellationError() } - return lifecycleEpoch - } - - private func requireCurrent(_ epoch: UInt64) throws { - guard deactivationCount == 0, - lifecycleEpoch == epoch else { throw CancellationError() } - } - - private func readSecureRecord(epoch: UInt64) async throws -> Data? { - try requireCurrent(epoch) - let data = try await secureStore.read(account: Self.storageAccount) - try requireCurrent(epoch) - return data - } - - private func writeSecureRecord(_ data: Data, epoch: UInt64) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.write( - data, - account: Self.storageAccount, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - try requireCurrent(epoch) - } - - private func deleteSecureRecord(epoch: UInt64) async throws { - try requireCurrent(epoch) - activeStorageMutationCount += 1 - defer { finishStorageMutation() } - try await secureStore.delete(account: Self.storageAccount) - try requireCurrent(epoch) - } - - private func finishStorageMutation() { - activeStorageMutationCount -= 1 - guard activeStorageMutationCount == 0 else { return } - let waiters = storageMutationDrainWaiters - storageMutationDrainWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - } - - private func waitForStorageMutations() async { - guard activeStorageMutationCount > 0 else { return } - await withCheckedContinuation { continuation in - storageMutationDrainWaiters.append(continuation) - } - } - - private func validate( - _ policy: CmxIrohCachedHostPolicy, - for expectation: CmxIrohHostPolicyExpectation, - now: Date - ) throws { - let binding = policy.binding - guard binding.deviceID == expectation.deviceID, - binding.appInstanceID == expectation.appInstanceID, - binding.tag == expectation.tag, - binding.platform == .mac, - binding.endpointID == expectation.endpointID, - binding.identityGeneration == expectation.identityGeneration, - policy.pairingEnabled == expectation.pairingEnabled, - policy.capabilities.count == expectation.capabilities.count, - Set(policy.capabilities) == Set(expectation.capabilities), - policy.endpointAttestation.attestationVersion == 1, - policy.endpointAttestation.grantVerificationKeys - == policy.grantVerificationKeys else { - throw CmxIrohHostPolicyCacheError.policyMismatch - } - let claims = try verifier.verifyEndpointAttestation( - policy.endpointAttestation.attestation, - keys: policy.grantVerificationKeys, - expected: CmxIrohEndpointExpectation( - bindingID: binding.bindingID, - deviceID: binding.deviceID, - endpointID: binding.endpointID, - identityGeneration: binding.identityGeneration, - platform: binding.platform - ), - now: now - ) - guard let envelopeExpiry = CmxIrohISO8601Date.parse( - policy.endpointAttestation.expiresAt - ), - let envelopeExpirySeconds = Self.seconds(envelopeExpiry), - envelopeExpirySeconds == claims.expiresAt, - envelopeExpiry > now else { - throw CmxIrohHostPolicyCacheError.invalidAttestationEnvelope - } - } - - private static func scopeDigest( - for expectation: CmxIrohHostPolicyExpectation - ) -> String { - let transcript = Data( - "cmux/iroh/offline-host-policy-scope/v1\0\(expectation.accountID)\0\(expectation.appInstanceID)".utf8 - ) - return SHA256.hash(data: transcript) - .map { String(format: "%02x", $0) } - .joined() - } - - private static func seconds(_ date: Date) -> Int64? { - let value = date.timeIntervalSince1970 - guard value.isFinite, - value >= TimeInterval(Int64.min), - value <= TimeInterval(Int64.max) else { - return nil - } - return Int64(value.rounded(.down)) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyCacheError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyCacheError.swift deleted file mode 100644 index 8b3f69d0..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyCacheError.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// A local host-policy cache validation failure that contains no account or credential data. -public enum CmxIrohHostPolicyCacheError: Error, Equatable, Sendable { - /// The caller supplied a malformed account, installation, or endpoint expectation. - case invalidExpectation - - /// The policy cannot safely authorize an offline Mac host. - case invalidPolicy - - /// The cached policy does not match the caller's current local identity and settings. - case policyMismatch - - /// The broker envelope expiry does not match the signed attestation expiry. - case invalidAttestationEnvelope -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyExpectation.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyExpectation.swift deleted file mode 100644 index 6917b294..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostPolicyExpectation.swift +++ /dev/null @@ -1,89 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// The current local Mac identity and settings that an offline policy must match exactly. -public struct CmxIrohHostPolicyExpectation: Equatable, Sendable { - /// The authenticated account identifier, used only to derive an opaque cache scope. - public let accountID: String - - /// The account device UUID owned by this Mac installation. - public let deviceID: String - - /// The current app-instance UUID, which changes when the account or build tag changes. - public let appInstanceID: String - - /// The build tag registered with the trust broker. - public let tag: String - - /// The EndpointID derived from the current local Iroh secret key. - public let endpointID: CmxIrohPeerIdentity - - /// The current local identity generation. - public let identityGeneration: Int - - /// Whether offline same-account pairing is currently enabled. - public let pairingEnabled: Bool - - /// The complete host capability set expected by this build. - public let capabilities: [String] - - /// Creates a validated local expectation for an offline policy lookup. - /// - /// The raw account identifier remains transient. The cache persists only a - /// SHA-256 scope derived from the account and app-instance identifiers. - /// - /// - Parameters: - /// - accountID: The current authenticated account identifier. - /// - deviceID: The account device's lowercase UUID. - /// - appInstanceID: The installation's lowercase app-instance UUID. - /// - tag: The safe build tag used for broker registration. - /// - endpointID: The current local Iroh EndpointID. - /// - identityGeneration: The positive local identity generation. - /// - pairingEnabled: Whether offline same-account pairing is enabled. - /// - capabilities: The complete bounded host capability set. - /// - Throws: ``CmxIrohHostPolicyCacheError/invalidExpectation`` for malformed input. - public init( - accountID: String, - deviceID: String, - appInstanceID: String, - tag: String, - endpointID: CmxIrohPeerIdentity, - identityGeneration: Int, - pairingEnabled: Bool, - capabilities: [String] - ) throws { - guard !accountID.isEmpty, - accountID.utf8.count <= 1_024, - Self.isCanonicalUUID(deviceID), - Self.isCanonicalUUID(appInstanceID), - Self.isSafeToken(tag), - (1 ... Int(Int32.max)).contains(identityGeneration), - capabilities.count <= 32, - Set(capabilities).count == capabilities.count, - capabilities.allSatisfy(Self.isSafeToken) else { - throw CmxIrohHostPolicyCacheError.invalidExpectation - } - self.accountID = accountID - self.deviceID = deviceID - self.appInstanceID = appInstanceID - self.tag = tag - self.endpointID = endpointID - self.identityGeneration = identityGeneration - self.pairingEnabled = pairingEnabled - self.capabilities = capabilities - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func isSafeToken(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift deleted file mode 100644 index efaa6ec1..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PolicyRefresh.swift +++ /dev/null @@ -1,472 +0,0 @@ -import CMUXMobileCore -import Foundation - -extension CmxIrohHostRuntime { - func resolveInitialPolicy( - supervisor: CmxIrohEndpointSupervisor, - expectedEndpointID: CmxIrohPeerIdentity, - revision: UInt64 - ) async throws -> ResolvedPolicy { - try await revokePendingBeforeRegistration() - try requireCurrent(revision) - var failureCount = 0 - while true { - try requireCurrent(revision) - do { - return try await resolvePolicyAfterPendingRevocations( - supervisor: supervisor, - expectedEndpointID: expectedEndpointID, - revision: revision, - allowCachedFallback: true - ) - } catch is CancellationError { - throw CancellationError() - } catch { - try requireCurrent(revision) - guard CmxIrohTrustBrokerClientError - .retriesInitialActivation(error) else { - throw error - } - let delay = registrationRetrySchedule.delay( - failureCount: failureCount, - retryAfterSeconds: (error as? CmxIrohTrustBrokerClientError)? - .retryAfterSeconds, - jitterUnitInterval: registrationRetryJitter() - ) - failureCount = min(failureCount + 1, 20) - let deadline = registrationClock.now().addingTimeInterval(delay) - // This bounded broker backoff is the intended delay; the - // lifecycle-owned start task cancels the injected clock sleep. - try await registrationClock.sleep(until: deadline) - } - } - } - - func resolvePolicy( - supervisor: CmxIrohEndpointSupervisor, - expectedEndpointID: CmxIrohPeerIdentity, - revision: UInt64, - allowCachedFallback: Bool - ) async throws -> ResolvedPolicy { - try await revokePendingBeforeRegistration() - try requireCurrent(revision) - return try await resolvePolicyAfterPendingRevocations( - supervisor: supervisor, - expectedEndpointID: expectedEndpointID, - revision: revision, - allowCachedFallback: allowCachedFallback - ) - } - - private func revokePendingBeforeRegistration() async throws { - try await pendingRevocations.revokePending( - accountID: configuration.accountID, - beforeRegisteringTag: configuration.tag, - using: broker - ) - } - - private func resolvePolicyAfterPendingRevocations( - supervisor: CmxIrohEndpointSupervisor, - expectedEndpointID: CmxIrohPeerIdentity, - revision: UInt64, - allowCachedFallback: Bool - ) async throws -> ResolvedPolicy { - let endpoint = try await supervisor.activeEndpoint() - let address = await endpoint.address() - guard address.identity == expectedEndpointID else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - let publicHints = Array(address.pathHints.compactMap { - $0.publicDisclosure(at: now()) - }.prefix(CmxAttachEndpoint.maximumIrohPathHintCount)) - let directPorts = CmxIrohDirectPorts( - localDirectAddresses: await endpoint.localDirectAddresses() - ) - let payload = try CmxIrohRegistrationPayload( - deviceID: configuration.deviceID, - appInstanceID: configuration.appInstanceID, - tag: configuration.tag, - platform: .mac, - displayName: configuration.displayName, - endpointID: expectedEndpointID.endpointID, - identityGeneration: configuration.identity.generation, - pairingEnabled: configuration.pairingEnabled, - capabilities: configuration.capabilities, - pathHints: publicHints, - directPorts: directPorts, - now: now() - ) - let signer = try CmxIrohRegistrationSigner( - identity: configuration.identity, - endpointID: expectedEndpointID.endpointID - ) - let prepared = try signer.prepare(payload: payload) - let registration: CmxIrohRegistrationResponse - do { - registration = try await broker.register(prepared: prepared, signer: signer) - } catch { - return try cachedPolicy( - after: error, - expectedEndpointID: expectedEndpointID, - confirmedBinding: nil, - relayBootstrap: nil, - allowFallback: allowCachedFallback - ) - } - try requireCurrent(revision) - try validateLocalBinding(registration.binding, endpointID: expectedEndpointID) - let discovery: CmxIrohDiscoveryResponse - do { - discovery = try await broker.discover() - } catch { - return try cachedPolicy( - after: error, - expectedEndpointID: expectedEndpointID, - confirmedBinding: registration.binding, - relayBootstrap: nil, - allowFallback: allowCachedFallback - ) - } - try requireCurrent(revision) - guard discovery.routeContractVersion == payload.routeContractVersion else { - throw CmxIrohHostRuntimeError.routeContractMismatch - } - guard Set(discovery.relayFleet) == managedRelayURLs, - discovery.relayFleet.count == managedRelayURLs.count else { - throw CmxIrohHostRuntimeError.relayFleetMismatch - } - guard let discovered = discovery.bindings.first(where: { - $0.bindingID == registration.binding.bindingID - }) else { - throw CmxIrohHostRuntimeError.localBindingMissingFromDiscovery - } - try validateLocalBinding(discovered, endpointID: expectedEndpointID) - let attestation = try? await broker.issueEndpointAttestation( - bindingID: discovered.bindingID - ) - try requireCurrent(revision) - return ResolvedPolicy( - registration: registration, - discovery: discovery, - binding: CmxIrohBrokerBindingMetadata(binding: discovered), - pairingEnabled: discovered.pairingEnabled, - grantVerificationKeys: discovery.grantVerificationKeys, - attestation: attestation, - relayBootstrap: configuration.cachedRelayCredential, - lanRendezvous: discovery.lanRendezvous - ) - } - - func cachedPolicy( - after error: any Error, - expectedEndpointID: CmxIrohPeerIdentity, - confirmedBinding: CmxIrohBrokerBinding?, - relayBootstrap: CmxIrohRelayTokenResponse?, - allowFallback: Bool - ) throws -> ResolvedPolicy { - if let confirmedBinding, let localBinding, - CmxIrohBrokerBindingMetadata(binding: confirmedBinding) != localBinding { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - guard allowFallback, Self.isConnectivityFailure(error), - let cached = configuration.cachedHostPolicy else { - throw error - } - try validateCachedPolicy(cached, endpointID: expectedEndpointID) - if let confirmedBinding { - guard CmxIrohBrokerBindingMetadata(binding: confirmedBinding) == cached.binding, - confirmedBinding.pairingEnabled == cached.pairingEnabled, - confirmedBinding.capabilities.count == cached.capabilities.count, - Set(confirmedBinding.capabilities) == Set(cached.capabilities) else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - } - return ResolvedPolicy( - registration: nil, - discovery: nil, - binding: cached.binding, - pairingEnabled: cached.pairingEnabled, - grantVerificationKeys: cached.grantVerificationKeys, - attestation: cached.endpointAttestation, - relayBootstrap: relayBootstrap ?? configuration.cachedRelayCredential, - lanRendezvous: cached.lanRendezvous - ) - } - - func validateLocalBinding( - _ binding: CmxIrohBrokerBinding, - endpointID: CmxIrohPeerIdentity - ) throws { - guard binding.deviceID == configuration.deviceID, - binding.appInstanceID == configuration.appInstanceID, - binding.tag == configuration.tag, - binding.platform == .mac, - binding.endpointID == endpointID, - binding.identityGeneration == configuration.identity.generation, - binding.pairingEnabled == configuration.pairingEnabled, - Set(binding.capabilities) == Set(configuration.capabilities), - binding.capabilities.count == configuration.capabilities.count else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - } - - func validateCachedPolicy( - _ policy: CmxIrohCachedHostPolicy, - endpointID: CmxIrohPeerIdentity - ) throws { - let binding = policy.binding - guard binding.deviceID == configuration.deviceID, - binding.appInstanceID == configuration.appInstanceID, - binding.tag == configuration.tag, - binding.platform == .mac, - binding.endpointID == endpointID, - binding.identityGeneration == configuration.identity.generation, - policy.pairingEnabled == configuration.pairingEnabled, - policy.capabilities.count == configuration.capabilities.count, - Set(policy.capabilities) == Set(configuration.capabilities), - policy.endpointAttestation.grantVerificationKeys - == policy.grantVerificationKeys else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - let validationTime = now() - let claims = try CmxIrohGrantVerifier().verifyEndpointAttestation( - policy.endpointAttestation.attestation, - keys: policy.grantVerificationKeys, - expected: endpointExpectation(for: binding), - now: validationTime - ) - guard let envelopeExpiry = CmxIrohISO8601Date.parse(policy.endpointAttestation.expiresAt), - Self.seconds(envelopeExpiry) == claims.expiresAt, - envelopeExpiry > validationTime else { - throw CmxIrohHostPolicyCacheError.invalidAttestationEnvelope - } - } - - func cachedRelayConfigurations() -> [CmxIrohRelayConfiguration] { - guard let cached = configuration.cachedRelayCredential, - Set(cached.relayFleet) == managedRelayURLs, - cached.relayFleet.count == managedRelayURLs.count else { - return [] - } - return (try? cached.relayConfigurations(now: now())) ?? [] - } - - func startSupervisorObservation( - supervisor: CmxIrohEndpointSupervisor, - revision: UInt64 - ) async { - supervisorEventTask?.cancel() - let events = await supervisor.events() - supervisorEventTask = Task { [weak self] in - for await event in events { - guard !Task.isCancelled else { return } - switch event { - case .networkChanged, .recovered: - await self?.handleSupervisorNetworkChange(revision: revision) - case .snapshot: - break - } - } - } - } - - func handleSupervisorNetworkChange(revision: UInt64) async { - guard lifecycleRevision == revision, - lifecyclePhase.ownsNetworkOperation else { return } - await handleLANRefresh() - guard lifecycleRevision == revision, - lifecyclePhase.ownsNetworkOperation else { return } - guard registrationRefreshEnabled else { - registrationRefreshPending = true - return - } - scheduleRegistrationRefresh(revision: revision) - } - - func scheduleRegistrationRefresh(revision: UInt64) { - guard lifecyclePhase == .active, - lifecycleRevision == revision else { return } - guard registrationRefreshTask == nil else { - // Address watchers may publish again while an earlier broker round - // is suspended. Preserve that newer snapshot as a dirty bit so the - // running round cannot overwrite the final usable relay address. - registrationRefreshPending = true - return - } - registrationRefreshPending = false - registrationRefreshTask = Task { [weak self] in - await self?.refreshRegistration(revision: revision) - } - } - - func scheduleRegistrationRenewal( - binding: CmxIrohBrokerBinding, - revision: UInt64 - ) { - registrationRenewalTask?.cancel() - registrationRenewalTask = nil - guard lifecyclePhase.ownsNetworkOperation, - lifecycleRevision == revision, - let deadline = Self.registrationRenewalDeadline( - binding: binding, - now: registrationClock.now() - ) else { return } - registrationRenewalTask = Task { [weak self] in - await self?.runRegistrationRenewal( - revision: revision, - firstDeadline: deadline - ) - } - } - - private func runRegistrationRenewal( - revision: UInt64, - firstDeadline: Date - ) async { - do { - try await registrationClock.sleep(until: firstDeadline) - } catch { - return - } - guard lifecyclePhase == .active, - lifecycleRevision == revision, - !Task.isCancelled else { return } - scheduleRegistrationRefresh(revision: revision) - await registrationRefreshTask?.value - } - - private func scheduleRegistrationRetry( - revision: UInt64, - error: any Error - ) { - guard lifecyclePhase == .active, - lifecycleRevision == revision else { return } - let delay = registrationRetrySchedule.delay( - failureCount: registrationRefreshFailureCount, - retryAfterSeconds: (error as? CmxIrohTrustBrokerClientError)? - .retryAfterSeconds, - jitterUnitInterval: registrationRetryJitter() - ) - registrationRefreshFailureCount = min( - registrationRefreshFailureCount + 1, - 20 - ) - registrationRenewalTask?.cancel() - let deadline = registrationClock.now().addingTimeInterval(delay) - registrationRenewalTask = Task { [weak self] in - await self?.runRegistrationRenewal( - revision: revision, - firstDeadline: deadline - ) - } - } - - static func registrationRenewalDeadline( - binding: CmxIrohBrokerBinding, - now: Date - ) -> Date? { - guard let expiry = binding.pathHints.compactMap(\.expiresAt).min(), - expiry > now else { return nil } - let remaining = expiry.timeIntervalSince(now) - let safetyWindow = min(15 * 60, max(30, remaining / 4)) - return max(now, expiry.addingTimeInterval(-safetyWindow)) - } - - func refreshRegistration(revision: UInt64) async { - var completedSuccessfully = false - defer { - if lifecycleRevision == revision { - registrationRefreshTask = nil - if completedSuccessfully, - registrationRefreshPending, - lifecyclePhase == .active { - scheduleRegistrationRefresh(revision: revision) - } - } - } - guard lifecyclePhase == .active, - lifecycleRevision == revision, - let supervisor, - let admissionController, - let previousBinding = localBinding else { return } - do { - let endpoint = try await supervisor.activeEndpoint() - let endpointID = await endpoint.identity() - let policy = try await resolvePolicy( - supervisor: supervisor, - expectedEndpointID: endpointID, - revision: revision, - allowCachedFallback: false - ) - guard policy.binding.bindingID == previousBinding.bindingID else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - await admissionController.update( - keys: policy.grantVerificationKeys, - acceptor: grantPeer(for: policy.binding), - pairingEnabled: policy.pairingEnabled - ) - try requireCurrent(revision) - localBinding = policy.binding - endpointAttestation = policy.attestation ?? endpointAttestation - lanRendezvous = policy.lanRendezvous - guard let registration = policy.registration, - let discovery = policy.discovery else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - await handleBinding(registration, discovery, policy.attestation) - try requireCurrent(revision) - scheduleLANPublication( - binding: policy.binding, - rendezvous: policy.lanRendezvous, - supervisor: supervisor, - revision: revision - ) - registrationRefreshFailureCount = 0 - completedSuccessfully = true - scheduleRegistrationRenewal( - binding: registration.binding, - revision: revision - ) - } catch is CancellationError { - return - } catch { - guard lifecyclePhase == .active, - lifecycleRevision == revision else { return } - guard CmxIrohTrustBrokerClientError - .preservesVerifiedPolicyDuringRefresh(error) else { - lifecyclePhase = .stopping - lifecycleRevision &+= 1 - let failureRevision = lifecycleRevision - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .failed, - endpointID: nil, - bindingID: localBinding?.bindingID - ) - await tearDownComponents(notify: true) - if lifecyclePhase == .stopping, - lifecycleRevision == failureRevision { - lifecyclePhase = .failed - } - return - } - // One retry owner honors both bounded exponential backoff and the - // broker's validated Retry-After floor. A later retry re-reads the - // endpoint, so address changes observed during this failed round are - // already included without an immediate duplicate broker request. - registrationRefreshPending = false - scheduleRegistrationRetry(revision: revision, error: error) - } - } - - static func seconds(_ date: Date) -> Int64? { - let value = date.timeIntervalSince1970 - guard value.isFinite, - value >= TimeInterval(Int64.min), - value <= TimeInterval(Int64.max) else { - return nil - } - return Int64(value.rounded(.down)) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PublicAPI.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PublicAPI.swift deleted file mode 100644 index e07aca69..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+PublicAPI.swift +++ /dev/null @@ -1,117 +0,0 @@ -public import CMUXMobileCore -import Foundation - -extension CmxIrohHostRuntime { - public func snapshot() -> CmxIrohHostRuntimeSnapshot { - currentSnapshot - } - - /// Returns the most recently admitted live path with coordinates removed. - /// - /// Relay attribution succeeds only when the selected relay is present in - /// the exact verified effective policy installed by the composition root. - /// - /// - Parameter relayPolicy: The current verified effective relay policy. - /// - Returns: A credential-free path category safe for settings and diagnostics. - public func selectedTransportPath( - relayPolicy: CmxIrohEffectiveRelayPolicy? - ) async -> CmxIrohSelectedTransportPath { - guard let id = activePathConnectionOrder.last, - let connection = activePathConnections[id] as? any CmxIrohConnectionPathInspecting else { - return .unavailable - } - let observed = await connection.observedSelectedPath() - return CmxIrohSelectedTransportPathClassifier(policy: relayPolicy) - .classify(observed) - } - - /// Emits when admitted connection lifecycle may alter the selected path. - /// - /// Consumers re-read ``selectedTransportPath(relayPolicy:)`` for the - /// credential-free value. The stream never carries raw path data. - public func selectedTransportPathChanges() -> AsyncStream<Void> { - let id = UUID() - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - selectedPathContinuations[id] = continuation - continuation.yield(()) - continuation.onTermination = { @Sendable [weak self] _ in - Task { await self?.removeSelectedPathContinuation(id: id) } - } - } - } - - /// Returns current verified private alias material without broker path hints. - public func lanAdvertisementContext() -> CmxIrohHostLANAdvertisementContext? { - guard lifecyclePhase == .active, - let localBinding, - let lanRendezvous else { return nil } - return CmxIrohHostLANAdvertisementContext( - binding: localBinding, - rendezvous: lanRendezvous - ) - } - - /// Reads raw local direct addresses only for the interface-filtering publisher. - public func localDirectAddresses() async -> [String] { - guard lifecyclePhase == .active, - let endpoint = try? await supervisor?.activeEndpoint() else { return [] } - return await endpoint.localDirectAddresses() - } - - /// Closes networking, durably queues revocation, then deactivates local state. - /// - /// The binding is captured and the lifecycle enters `signingOut` before the - /// first suspension. Endpoint teardown and device-only persistence run - /// concurrently. App-visible network state is cleared on either outcome. - /// Persistence failure leaves identity state and the binding quarantined. - /// Calling this method again while quarantined retries the durable enqueue. - /// - /// - Returns: The prior binding and whether it was durably queued. - public func deactivateForSignOut() async -> CmxIrohHostSignOutPreparation { - if let signOutOperation { - return await signOutOperation.value - } - let requiresNetworkDeactivation = lifecyclePhase != .quarantined - let pendingRevocation = localBinding.flatMap { binding in - try? CmxIrohPendingRevocation( - accountID: configuration.accountID, - tag: configuration.tag, - bindingID: binding.bindingID - ) - } - lifecyclePhase = .signingOut - lifecycleRevision &+= 1 - let revision = lifecycleRevision - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .signingOut, - endpointID: currentSnapshot.endpointID, - bindingID: pendingRevocation?.bindingID - ) - - let operation = Task { - await self.performSignOut( - pendingRevocation: pendingRevocation, - requiresNetworkDeactivation: requiresNetworkDeactivation, - revision: revision - ) - } - signOutOperation = operation - return await operation.value - } - - /// Creates a one-use five-minute offline invitation from the latest broker proof. - public func createOfflinePairingInvitation() async throws -> CmxIrohOfflinePairingInvitation { - guard lifecyclePhase == .active, - let offlineSessions, - let binding = localBinding, - let attestation = endpointAttestation else { - throw CmxIrohHostRuntimeError.inactive - } - return try await offlineSessions.createInvitation( - acceptorAttestation: attestation.attestation, - keys: attestation.grantVerificationKeys, - acceptor: endpointExpectation(for: binding), - now: now() - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift deleted file mode 100644 index 90172a28..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+RelayPolicy.swift +++ /dev/null @@ -1,83 +0,0 @@ -extension CmxIrohHostRuntime { - /// Installs a resolved relay policy without recreating the endpoint or sessions. - public func replaceRelayPolicy( - _ policy: CmxIrohEffectiveRelayPolicy - ) async throws { - let verifiedManagedURLs = policy.managedPolicy.map { - Set($0.relays.map(\.url)) - } ?? managedRelayURLs - try await replaceRelayProfile( - policy.endpointRelayProfile, - managedRelayURLs: verifiedManagedURLs, - relayBootstrap: policy.relayBootstrap - ) - } - - /// Installs an endpoint relay profile against the current verified managed fleet. - public func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile - ) async throws { - try await replaceRelayProfile( - profile, - managedRelayURLs: managedRelayURLs, - relayBootstrap: nil - ) - } - - private func replaceRelayProfile( - _ profile: CmxIrohEndpointRelayProfile, - managedRelayURLs replacementManagedURLs: Set<String>, - relayBootstrap: CmxIrohRelayTokenResponse? - ) async throws { - guard lifecyclePhase == .active, - let supervisor, - let binding = localBinding else { - throw CmxIrohHostRuntimeError.inactive - } - guard (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - replacementManagedURLs.count - ), - profile.source == .custom - || profile.allowedRelayURLs.isSubset(of: replacementManagedURLs) else { - throw CmxIrohHostRuntimeError.relayFleetMismatch - } - let revision = lifecycleRevision - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: binding.endpointID - ) - try requireCurrent(revision) - - managedRelayURLs = replacementManagedURLs - currentEndpointRelayProfile = profile - await admissionController?.updateManagedRelayURLs(replacementManagedURLs) - try requireCurrent(revision) - - relayActivationTask?.cancel() - relayActivationTask = nil - await relayCoordinator?.deactivate() - relayCoordinator = nil - guard profile.source == .managed, - !profile.allowedRelayURLs.isEmpty else { return } - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: replacementManagedURLs, - selectedRelayURLs: profile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, binding) - } - ) - relayCoordinator = coordinator - do { - try await coordinator.activate( - bindingID: binding.bindingID, - endpointIdentity: binding.endpointID, - bootstrap: relayBootstrap - ) - } catch { - // The verified allowlist is already live; direct paths remain usable - // while the coordinator retries a managed credential refresh. - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift deleted file mode 100644 index 1907cc4b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime+SignOut.swift +++ /dev/null @@ -1,114 +0,0 @@ -public import Foundation - -extension CmxIrohHostRuntime { - func performSignOut( - pendingRevocation: CmxIrohPendingRevocation?, - requiresNetworkDeactivation: Bool, - revision: UInt64 - ) async -> CmxIrohHostSignOutPreparation { - async let wasPersisted = Self.persist( - pendingRevocation, - to: pendingRevocations - ) - async let networkTeardown: Void = deactivateNetworkForSignOut( - bindingID: pendingRevocation?.bindingID, - required: requiresNetworkDeactivation - ) - let (persisted, _) = await (wasPersisted, networkTeardown) - let preparation = CmxIrohHostSignOutPreparation( - pendingRevocation: pendingRevocation, - wasPersisted: persisted - ) - - guard lifecyclePhase == .signingOut, - lifecycleRevision == revision else { - signOutOperation = nil - return preparation - } - guard persisted else { - lifecyclePhase = .quarantined - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .quarantined, - endpointID: nil, - bindingID: pendingRevocation?.bindingID - ) - signOutOperation = nil - return preparation - } - - localBinding = nil - lifecyclePhase = .inactive - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .inactive, - endpointID: nil, - bindingID: nil - ) - signOutOperation = nil - return preparation - } - - nonisolated static func persist( - _ revocation: CmxIrohPendingRevocation?, - to pendingRevocations: CmxIrohPendingRevocationOutbox - ) async -> Bool { - guard let revocation else { return true } - do { - try await pendingRevocations.enqueue(revocation) - return true - } catch { - return false - } - } - - func deactivateNetworkForSignOut( - bindingID: String?, - required: Bool - ) async { - guard required else { return } - await tearDownComponents(notify: false, preserveBinding: true) - await handleDeactivation(bindingID) - } - - func tearDownComponents( - notify: Bool, - preserveBinding: Bool = false - ) async { - supervisorEventTask?.cancel() - supervisorEventTask = nil - registrationRefreshTask?.cancel() - registrationRefreshTask = nil - registrationRenewalTask?.cancel() - registrationRenewalTask = nil - registrationRefreshPending = false - registrationRefreshEnabled = false - registrationRefreshFailureCount = 0 - relayActivationTask?.cancel() - relayActivationTask = nil - lanPublicationGeneration &+= 1 - lanPublicationTask?.cancel() - lanPublicationTask = nil - await endpointServer?.stop() - endpointServer = nil - activePathConnections.removeAll(keepingCapacity: false) - activePathConnectionOrder.removeAll(keepingCapacity: false) - for task in activePathObservationTasks.values { task.cancel() } - activePathObservationTasks.removeAll(keepingCapacity: false) - publishSelectedPathChange() - await relayCoordinator?.deactivate() - relayCoordinator = nil - await offlineSessions?.invalidate() - offlineSessions = nil - await onlineAdmissionRegistry?.stop() - onlineAdmissionRegistry = nil - admissionController = nil - let bindingID = localBinding?.bindingID - if !preserveBinding { - localBinding = nil - } - endpointAttestation = nil - lanRendezvous = nil - await supervisor?.deactivate() - supervisor = nil - if notify { await handleDeactivation(bindingID) } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift deleted file mode 100644 index 1821da2d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +++ /dev/null @@ -1,604 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Owns one account-scoped Mac endpoint, broker binding, relay rotation, and accept loop. -public actor CmxIrohHostRuntime { - public typealias CurrentGeneration = @Sendable () async -> Bool - public typealias TransportHandler = @Sendable ( - _ session: CmxIrohAdmittedServerSession, - _ isCurrent: @escaping CurrentGeneration - ) async -> Void - public typealias BindingHandler = @Sendable ( - _ registration: CmxIrohRegistrationResponse, - _ discovery: CmxIrohDiscoveryResponse, - _ attestation: CmxIrohEndpointAttestationResponse? - ) async -> Void - /// Clears app-visible network state after the endpoint and accepts are closed. - /// - /// Persistent identity and credential deletion belongs to the caller and - /// must remain conditional on a successfully queued sign-out revocation. - public typealias DeactivationHandler = @Sendable (_ bindingID: String?) async -> Void - public typealias RelayCredentialHandler = @Sendable ( - _ response: CmxIrohRelayTokenResponse, - _ binding: CmxIrohBrokerBindingMetadata - ) async -> Void - public typealias LANRefreshHandler = @Sendable () async -> Void - public typealias LANDirectAddressProvider = @Sendable () async -> [String] - public typealias LANPolicyHandler = @Sendable ( - _ context: CmxIrohHostLANAdvertisementContext, - _ directAddresses: @escaping LANDirectAddressProvider - ) async -> Void - - struct ResolvedPolicy: Sendable { - let registration: CmxIrohRegistrationResponse? - let discovery: CmxIrohDiscoveryResponse? - let binding: CmxIrohBrokerBindingMetadata - let pairingEnabled: Bool - let grantVerificationKeys: CmxIrohGrantVerificationKeySet - let attestation: CmxIrohEndpointAttestationResponse? - let relayBootstrap: CmxIrohRelayTokenResponse? - let lanRendezvous: CmxIrohLANRendezvous - } - - enum LifecyclePhase: Equatable, Sendable { - case inactive - case starting - case active - case stopping - case signingOut - case quarantined - case failed - - var allowsStart: Bool { - self == .inactive || self == .failed - } - - var ownsNetworkOperation: Bool { - self == .starting || self == .active - } - } - - let factory: any CmxIrohEndpointFactory - let broker: any CmxIrohHostBrokerServing - let configuration: CmxIrohHostRuntimeConfiguration - let pendingRevocations: CmxIrohPendingRevocationOutbox - let protocolConfiguration: CmxIrohProtocolConfiguration - let now: @Sendable () -> Date - let admissionClock: any CmxIrohRelayClock - let registrationClock: any CmxIrohRelayClock - let registrationRetrySchedule: CmxIrohRetrySchedule - let registrationRetryJitter: @Sendable () -> Double - let handleTransport: TransportHandler - let handleBinding: BindingHandler - let handleDeactivation: DeactivationHandler - let handleRelayCredential: RelayCredentialHandler - let handleLANRefresh: LANRefreshHandler - let handleLANPolicy: LANPolicyHandler - - var lifecycleRevision: UInt64 = 0 - var lifecyclePhase = LifecyclePhase.inactive - var signOutOperation: Task<CmxIrohHostSignOutPreparation, Never>? - var supervisor: CmxIrohEndpointSupervisor? - var relayCoordinator: CmxIrohRelayCredentialCoordinator? - var endpointServer: CmxIrohEndpointServer? - var admissionController: CmxIrohAdmissionController? - var onlineAdmissionRegistry: CmxIrohOnlineAdmissionRegistry? - var offlineSessions: CmxIrohOfflinePairingSessions? - var supervisorEventTask: Task<Void, Never>? - var relayActivationTask: Task<Void, Never>? - var lanPublicationTask: Task<Void, Never>? - var lanPublicationGeneration: UInt64 = 0 - var registrationRefreshTask: Task<Void, Never>? - var registrationRenewalTask: Task<Void, Never>? - var registrationRefreshPending = false - var registrationRefreshEnabled = false - var registrationRefreshFailureCount = 0 - var localBinding: CmxIrohBrokerBindingMetadata? - var managedRelayURLs: Set<String> - var currentEndpointRelayProfile: CmxIrohEndpointRelayProfile? - var endpointAttestation: CmxIrohEndpointAttestationResponse? - var lanRendezvous: CmxIrohLANRendezvous? - var activePathConnections: [UUID: any CmxIrohConnection] = [:] - var activePathConnectionOrder: [UUID] = [] - var activePathObservationTasks: [UUID: Task<Void, Never>] = [:] - var selectedPathContinuations: [UUID: AsyncStream<Void>.Continuation] = [:] - var currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .inactive, - endpointID: nil, - bindingID: nil - ) - - public init( - factory: any CmxIrohEndpointFactory, - broker: any CmxIrohHostBrokerServing, - configuration: CmxIrohHostRuntimeConfiguration, - pendingRevocations: CmxIrohPendingRevocationOutbox, - protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1, - now: @escaping @Sendable () -> Date = { Date() }, - admissionClock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - registrationClock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - registrationRetrySchedule: CmxIrohRetrySchedule = CmxIrohRetrySchedule(), - registrationRetryJitter: @escaping @Sendable () -> Double = { - Double.random(in: 0 ... 1) - }, - handleTransport: @escaping TransportHandler, - handleBinding: @escaping BindingHandler = { _, _, _ in }, - handleDeactivation: @escaping DeactivationHandler = { _ in }, - handleRelayCredential: @escaping RelayCredentialHandler = { _, _ in }, - handleLANRefresh: @escaping LANRefreshHandler = {}, - handleLANPolicy: @escaping LANPolicyHandler = { _, _ in } - ) { - self.factory = factory - self.broker = broker - self.configuration = configuration - self.pendingRevocations = pendingRevocations - self.protocolConfiguration = protocolConfiguration - self.now = now - self.admissionClock = admissionClock - self.registrationClock = registrationClock - self.registrationRetrySchedule = registrationRetrySchedule - self.registrationRetryJitter = registrationRetryJitter - self.handleTransport = handleTransport - self.handleBinding = handleBinding - self.handleDeactivation = handleDeactivation - self.handleRelayCredential = handleRelayCredential - self.handleLANRefresh = handleLANRefresh - self.handleLANPolicy = handleLANPolicy - managedRelayURLs = configuration.managedRelayURLs - currentEndpointRelayProfile = configuration.endpointRelayProfile - } - - - /// Activates connectivity and resolves authenticated broker policy before any cached fallback. - public func start() async throws { - guard lifecyclePhase.allowsStart else { - throw CmxIrohHostRuntimeError.alreadyActive - } - lifecyclePhase = .starting - lifecycleRevision &+= 1 - let revision = lifecycleRevision - registrationRefreshPending = false - registrationRefreshEnabled = false - registrationRefreshFailureCount = 0 - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .starting, - endpointID: nil, - bindingID: nil - ) - - do { - let endpointRelayProfile = try (currentEndpointRelayProfile - ?? configuration.resolvedEndpointRelayProfile(now: now())) - .droppingExpiredManagedCredentials(at: now()) - currentEndpointRelayProfile = endpointRelayProfile - let endpointConfiguration = CmxIrohEndpointConfiguration( - secretKey: configuration.identity.secretKey, - alpns: [protocolConfiguration.alpn], - bindPolicy: configuration.bindPolicy, - relayProfile: endpointRelayProfile - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration - ) - self.supervisor = supervisor - await startSupervisorObservation( - supervisor: supervisor, - revision: revision - ) - let endpointSnapshot = try await supervisor.activate() - try requireCurrent(revision) - guard let endpointID = endpointSnapshot.identity else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - - let policy = try await resolveInitialPolicy( - supervisor: supervisor, - expectedEndpointID: endpointID, - revision: revision - ) - try requireCurrent(revision) - - let offlineSessions = CmxIrohOfflinePairingSessions( - pairingEnabled: policy.pairingEnabled - ) - let onlineAdmissionRegistry = CmxIrohOnlineAdmissionRegistry( - broker: broker, - keys: policy.grantVerificationKeys, - acceptor: grantPeer(for: policy.binding), - managedRelayURLs: managedRelayURLs, - clock: admissionClock - ) - let admissionController = CmxIrohAdmissionController( - acceptor: grantPeer(for: policy.binding), - pairingEnabled: policy.pairingEnabled, - offlineSessions: offlineSessions, - onlineRegistry: onlineAdmissionRegistry - ) - let relayCoordinator: CmxIrohRelayCredentialCoordinator? - if endpointRelayProfile.source == .managed, - !endpointRelayProfile.allowedRelayURLs.isEmpty { - relayCoordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: managedRelayURLs, - selectedRelayURLs: endpointRelayProfile.allowedRelayURLs, - credentialDidInstall: { [handleRelayCredential] response in - await handleRelayCredential(response, policy.binding) - } - ) - } else { - relayCoordinator = nil - } - - self.offlineSessions = offlineSessions - self.onlineAdmissionRegistry = onlineAdmissionRegistry - self.admissionController = admissionController - self.relayCoordinator = relayCoordinator - localBinding = policy.binding - endpointAttestation = policy.attestation - lanRendezvous = policy.lanRendezvous - - let server = CmxIrohEndpointServer(supervisor: supervisor) { [weak self] connection, generation, markAdmitted in - guard let self else { - await connection.close(errorCode: 1, reason: "runtime_deallocated") - return - } - try await self.admit( - connection: connection, - runtimeGeneration: generation, - lifecycleRevision: revision, - markAdmitted: markAdmitted - ) - } - endpointServer = server - await server.start() - try requireCurrent(revision) - - lifecyclePhase = .active - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .active, - endpointID: endpointID, - bindingID: policy.binding.bindingID - ) - var publishedPolicy = policy - let requiresRelayReadiness = !protocolConfiguration - .allowsNATTraversalAfterAdmission - if requiresRelayReadiness { - if let relayCoordinator { - try await relayCoordinator.activate( - bindingID: policy.binding.bindingID, - endpointIdentity: endpointID, - bootstrap: policy.relayBootstrap - ) - } - try requireCurrent(revision) - guard await supervisor.hasConfiguredRelay() else { - throw CmxIrohEndpointSupervisorError.relayReadinessTimedOut - } - try await supervisor.waitForUsableHomeRelay() - try requireCurrent(revision) - let readyPolicy = try await resolvePolicy( - supervisor: supervisor, - expectedEndpointID: endpointID, - revision: revision, - allowCachedFallback: false - ) - guard readyPolicy.binding.bindingID == policy.binding.bindingID else { - throw CmxIrohHostRuntimeError.invalidLocalBinding - } - await admissionController.update( - keys: readyPolicy.grantVerificationKeys, - acceptor: grantPeer(for: readyPolicy.binding), - pairingEnabled: readyPolicy.pairingEnabled - ) - try requireCurrent(revision) - localBinding = readyPolicy.binding - endpointAttestation = readyPolicy.attestation ?? endpointAttestation - lanRendezvous = readyPolicy.lanRendezvous - publishedPolicy = readyPolicy - // The online event that released the barrier is already folded - // into `readyPolicy`; do not immediately publish a third copy. - registrationRefreshPending = false - } - if let registration = publishedPolicy.registration, - let discovery = publishedPolicy.discovery { - await handleBinding(registration, discovery, publishedPolicy.attestation) - scheduleRegistrationRenewal( - binding: registration.binding, - revision: revision - ) - } - registrationRefreshEnabled = true - if registrationRefreshPending { - registrationRefreshPending = false - scheduleRegistrationRefresh(revision: revision) - } - if let relayCoordinator, !requiresRelayReadiness { - scheduleRelayActivation( - relayCoordinator, - binding: policy.binding, - endpointID: endpointID, - bootstrap: policy.relayBootstrap, - revision: revision - ) - } - scheduleLANPublication( - binding: publishedPolicy.binding, - rendezvous: publishedPolicy.lanRendezvous, - supervisor: supervisor, - revision: revision - ) - } catch { - guard lifecyclePhase.ownsNetworkOperation, - lifecycleRevision == revision else { - throw error - } - lifecyclePhase = .stopping - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .failed, - endpointID: nil, - bindingID: localBinding?.bindingID - ) - await tearDownComponents(notify: true) - if lifecyclePhase == .stopping, - lifecycleRevision == revision { - lifecyclePhase = .failed - } - throw error - } - } - - /// Stops accepts, closes the endpoint, and invalidates generation-owned work. - public func stop() async { - guard lifecyclePhase == .starting || lifecyclePhase == .active else { - return - } - lifecyclePhase = .stopping - lifecycleRevision &+= 1 - let revision = lifecycleRevision - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .stopping, - endpointID: currentSnapshot.endpointID, - bindingID: localBinding?.bindingID - ) - await tearDownComponents(notify: true) - guard lifecyclePhase == .stopping, - lifecycleRevision == revision else { return } - lifecyclePhase = .inactive - currentSnapshot = CmxIrohHostRuntimeSnapshot( - state: .inactive, - endpointID: nil, - bindingID: nil - ) - } - - - private func admit( - connection: any CmxIrohConnection, - runtimeGeneration: UInt64, - lifecycleRevision revision: UInt64, - markAdmitted: @escaping CmxIrohEndpointServer.AdmissionMarker - ) async throws { - try requireCurrent(revision) - guard let admissionController, - let endpointServer, - await endpointServer.isCurrent(runtimeGeneration: runtimeGeneration) else { - throw CmxIrohHostRuntimeError.superseded - } - let session = try CmxIrohServerSession( - connection: connection, - authorizer: admissionController, - protocolConfiguration: protocolConfiguration - ) - let peer = try await session.admit() - let onlineLease = try await session.admittedOnlineLease() - guard await isCurrent(revision: revision, runtimeGeneration: runtimeGeneration) else { - await session.close() - throw CmxIrohHostRuntimeError.superseded - } - guard await markAdmitted() else { - await session.close() - throw CmxIrohHostRuntimeError.superseded - } - let isCurrent: CurrentGeneration = { [weak self] in - await self?.isCurrent( - revision: revision, - runtimeGeneration: runtimeGeneration - ) ?? false - } - if let onlineLease, let onlineAdmissionRegistry { - await onlineAdmissionRegistry.monitor( - onlineLease, - connection: connection - ) { - await session.close() - } - } - let pathConnectionID = UUID() - activePathConnections[pathConnectionID] = connection - activePathConnectionOrder.append(pathConnectionID) - if let inspecting = connection as? any CmxIrohConnectionPathInspecting { - activePathObservationTasks[pathConnectionID] = Task { [weak self] in - let changes = await inspecting.observedSelectedPathChanges() - for await _ in changes { - guard !Task.isCancelled else { return } - await self?.publishSelectedPathChange(connectionID: pathConnectionID) - } - } - } - publishSelectedPathChange() - defer { - activePathObservationTasks[pathConnectionID]?.cancel() - activePathObservationTasks[pathConnectionID] = nil - activePathConnections[pathConnectionID] = nil - activePathConnectionOrder.removeAll { $0 == pathConnectionID } - publishSelectedPathChange() - } - await handleTransport( - CmxIrohAdmittedServerSession(peer: peer, session: session), - isCurrent - ) - } - - func publishSelectedPathChange() { - for continuation in selectedPathContinuations.values { - continuation.yield(()) - } - } - - func publishSelectedPathChange(connectionID: UUID) { - guard activePathConnections[connectionID] != nil else { return } - publishSelectedPathChange() - } - - func removeSelectedPathContinuation(id: UUID) { - selectedPathContinuations[id] = nil - } - - private func isCurrent(revision: UInt64, runtimeGeneration: UInt64) async -> Bool { - guard lifecyclePhase == .active, - lifecycleRevision == revision, - let endpointServer else { return false } - return await endpointServer.isCurrent(runtimeGeneration: runtimeGeneration) - } - - func requireCurrent(_ revision: UInt64) throws { - guard lifecyclePhase.ownsNetworkOperation, - lifecycleRevision == revision, - !Task.isCancelled else { - throw CmxIrohHostRuntimeError.superseded - } - } - - func grantPeer( - for binding: CmxIrohBrokerBindingMetadata - ) -> CmxIrohGrantPeer { - CmxIrohGrantPeer( - bindingID: binding.bindingID, - deviceID: binding.deviceID, - tag: binding.tag, - platform: binding.platform, - endpointID: binding.endpointID, - identityGeneration: binding.identityGeneration - ) - } - - func publishLANPolicy( - binding: CmxIrohBrokerBindingMetadata, - rendezvous: CmxIrohLANRendezvous, - supervisor: CmxIrohEndpointSupervisor - ) async { - let context = CmxIrohHostLANAdvertisementContext( - binding: binding, - rendezvous: rendezvous - ) - let directAddresses: LANDirectAddressProvider = { - guard let endpoint = try? await supervisor.activeEndpoint() else { return [] } - return await endpoint.localDirectAddresses() - } - await handleLANPolicy(context, directAddresses) - } - - func scheduleRelayActivation( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) { - relayActivationTask?.cancel() - relayActivationTask = Task { [weak self] in - await self?.activateRelaySidecar( - coordinator, - binding: binding, - endpointID: endpointID, - bootstrap: bootstrap, - revision: revision - ) - } - } - - private func activateRelaySidecar( - _ coordinator: CmxIrohRelayCredentialCoordinator, - binding: CmxIrohBrokerBindingMetadata, - endpointID: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse?, - revision: UInt64 - ) async { - guard lifecyclePhase == .active, - lifecycleRevision == revision, - relayCoordinator === coordinator, - !Task.isCancelled else { return } - do { - try await coordinator.activate( - bindingID: binding.bindingID, - endpointIdentity: endpointID, - bootstrap: bootstrap - ) - } catch { - // The coordinator owns bounded retry. A verified direct route stays - // authoritative when relay credential installation is unavailable. - } - if relayCoordinator === coordinator { - relayActivationTask = nil - } - } - - func scheduleLANPublication( - binding: CmxIrohBrokerBindingMetadata, - rendezvous: CmxIrohLANRendezvous, - supervisor: CmxIrohEndpointSupervisor, - revision: UInt64 - ) { - lanPublicationGeneration &+= 1 - let generation = lanPublicationGeneration - lanPublicationTask?.cancel() - lanPublicationTask = Task { [weak self] in - await self?.publishLANSidecar( - binding: binding, - rendezvous: rendezvous, - supervisor: supervisor, - revision: revision, - generation: generation - ) - } - } - - private func publishLANSidecar( - binding: CmxIrohBrokerBindingMetadata, - rendezvous: CmxIrohLANRendezvous, - supervisor: CmxIrohEndpointSupervisor, - revision: UInt64, - generation: UInt64 - ) async { - guard lifecyclePhase == .active, - lifecycleRevision == revision, - lanPublicationGeneration == generation, - !Task.isCancelled else { return } - await publishLANPolicy( - binding: binding, - rendezvous: rendezvous, - supervisor: supervisor - ) - } - - func endpointExpectation( - for binding: CmxIrohBrokerBindingMetadata - ) -> CmxIrohEndpointExpectation { - CmxIrohEndpointExpectation( - bindingID: binding.bindingID, - deviceID: binding.deviceID, - endpointID: binding.endpointID, - identityGeneration: binding.identityGeneration, - platform: binding.platform - ) - } - - static func isConnectivityFailure(_ error: any Error) -> Bool { - guard let brokerError = error as? CmxIrohTrustBrokerClientError else { - return false - } - return brokerError == .connectivity - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift deleted file mode 100644 index dbcb21b6..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeConfiguration.swift +++ /dev/null @@ -1,71 +0,0 @@ -internal import CMUXMobileCore - -/// Stable, account-scoped inputs for one Mac Iroh host lifecycle. -public struct CmxIrohHostRuntimeConfiguration: Equatable, Sendable { - /// The authenticated account scope used only for pending-revocation isolation. - public let accountID: String - - public let deviceID: String - public let appInstanceID: String - public let tag: String - public let displayName: String? - public let identity: CmxIrohIdentityMaterial - public let pairingEnabled: Bool - public let capabilities: [String] - /// The UDP bind behavior applied to every endpoint generation. - public let bindPolicy: CmxIrohEndpointBindPolicy - public let managedRelayURLs: Set<String> - /// Optional selected-managed or strict-custom profile for the local endpoint. - /// - /// `nil` preserves automatic use of the complete managed fleet. - public let endpointRelayProfile: CmxIrohEndpointRelayProfile? - public let cachedRelayCredential: CmxIrohRelayTokenResponse? - /// A previously verified offline policy considered only after broker connectivity failure. - public let cachedHostPolicy: CmxIrohCachedHostPolicy? - - /// Creates stable inputs for one Mac host runtime lifecycle. - /// - /// - Parameters: - /// - accountID: The exact account that owns this host binding. - /// - deviceID: The account device's lowercase UUID. - /// - appInstanceID: The current app-instance UUID. - /// - tag: The broker registration build tag. - /// - displayName: The optional user-visible Mac name. - /// - identity: The stable Iroh secret and generation. - /// - pairingEnabled: Whether same-account pairing is enabled. - /// - capabilities: The complete host capability set. - /// - bindPolicy: The UDP bind behavior, ephemeral by default. - /// - managedRelayURLs: The exact managed relay allowlist. - /// - endpointRelayProfile: An optional local selection or custom override. - /// - cachedRelayCredential: A validated relay bootstrap for this endpoint. - /// - cachedHostPolicy: A policy previously verified by ``CmxIrohHostPolicyCache``. - public init( - accountID: String, - deviceID: String, - appInstanceID: String, - tag: String, - displayName: String?, - identity: CmxIrohIdentityMaterial, - pairingEnabled: Bool, - capabilities: [String], - bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - managedRelayURLs: Set<String>, - endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil, - cachedRelayCredential: CmxIrohRelayTokenResponse? = nil, - cachedHostPolicy: CmxIrohCachedHostPolicy? = nil - ) { - self.accountID = accountID - self.deviceID = cmxCanonicalDeviceID(deviceID) - self.appInstanceID = appInstanceID.lowercased() - self.tag = tag - self.displayName = displayName - self.identity = identity - self.pairingEnabled = pairingEnabled - self.capabilities = capabilities - self.bindPolicy = bindPolicy - self.managedRelayURLs = managedRelayURLs - self.endpointRelayProfile = endpointRelayProfile - self.cachedRelayCredential = cachedRelayCredential - self.cachedHostPolicy = cachedHostPolicy - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeError.swift deleted file mode 100644 index df1ec655..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeError.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// Fail-closed host composition errors. -public enum CmxIrohHostRuntimeError: Error, Equatable, Sendable { - case alreadyActive - case inactive - case invalidLocalBinding - case localBindingMissingFromDiscovery - case relayFleetMismatch - case routeContractMismatch - case superseded -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeSnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeSnapshot.swift deleted file mode 100644 index 81c636c8..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntimeSnapshot.swift +++ /dev/null @@ -1,28 +0,0 @@ -public import CMUXMobileCore - -/// Non-secret state exposed by the host runtime. -public struct CmxIrohHostRuntimeSnapshot: Equatable, Sendable { - public enum State: Equatable, Sendable { - case inactive - case starting - case active - case stopping - case signingOut - case quarantined - case failed - } - - public let state: State - public let endpointID: CmxIrohPeerIdentity? - public let bindingID: String? - - public init( - state: State, - endpointID: CmxIrohPeerIdentity?, - bindingID: String? - ) { - self.state = state - self.endpointID = endpointID - self.bindingID = bindingID - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIPAddressScope.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIPAddressScope.swift deleted file mode 100644 index 778744f8..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIPAddressScope.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Darwin -import Foundation - -/// Classifies a socket address without retaining it past initialization. -struct CmxIrohIPAddressScope: Sendable { - let isPrivate: Bool - - init(socketAddress: String) { - guard let host = Self.host(from: socketAddress) else { - isPrivate = false - return - } - let addressLiteral = host.split(separator: "%", maxSplits: 1).first.map(String.init) ?? host - - var ipv4 = in_addr() - if addressLiteral.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { - let address = UInt32(bigEndian: ipv4.s_addr) - let first = UInt8(truncatingIfNeeded: address >> 24) - let second = UInt8(truncatingIfNeeded: address >> 16) - isPrivate = first == 10 - || first == 127 - || (first == 100 && (64 ... 127).contains(second)) - || (first == 169 && second == 254) - || (first == 172 && (16 ... 31).contains(second)) - || (first == 192 && second == 168) - return - } - - var ipv6 = in6_addr() - if addressLiteral.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { - let bytes = withUnsafeBytes(of: &ipv6) { Array($0) } - let isUniqueLocal = bytes[0] & 0xfe == 0xfc - let isLinkLocal = bytes[0] == 0xfe && bytes[1] & 0xc0 == 0x80 - let isLoopback = bytes.dropLast().allSatisfy { $0 == 0 } && bytes.last == 1 - let isMappedIPv4 = bytes.prefix(10).allSatisfy { $0 == 0 } - && bytes[10] == 0xff - && bytes[11] == 0xff - if isMappedIPv4 { - let mapped = "\(bytes[12]).\(bytes[13]).\(bytes[14]).\(bytes[15]):0" - isPrivate = Self(socketAddress: mapped).isPrivate - } else { - isPrivate = isUniqueLocal || isLinkLocal || isLoopback - } - return - } - - isPrivate = false - } - - private static func host(from socketAddress: String) -> String? { - if socketAddress.first == "[", - let closingBracket = socketAddress.firstIndex(of: "]") { - return String(socketAddress[socketAddress.index(after: socketAddress.startIndex) ..< closingBracket]) - } - let colonCount = socketAddress.reduce(into: 0) { count, character in - if character == ":" { count += 1 } - } - if colonCount == 1, let colon = socketAddress.lastIndex(of: ":") { - return String(socketAddress[..<colon]) - } - return colonCount > 1 ? socketAddress : nil - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohISO8601Date.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohISO8601Date.swift deleted file mode 100644 index d9a3e320..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohISO8601Date.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -enum CmxIrohISO8601Date { - static func parse(_ value: String) -> Date? { - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) - } - - static func decode(from decoder: any Decoder) throws -> Date { - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - guard let date = parse(value) else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Invalid ISO 8601 broker date" - ) - } - return date - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityMaterial.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityMaterial.swift deleted file mode 100644 index 07c18230..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityMaterial.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Stable Iroh identity material for one signed-in account and app instance. -public struct CmxIrohIdentityMaterial: Equatable, Sendable { - /// The device-local Ed25519 secret that determines the EndpointID. - public let secretKey: CmxIrohSecretKey - - /// Monotonic generation changed only when this identity rotates. - public let generation: Int - - /// Creates validated identity material. - /// - /// - Parameters: - /// - secretKey: The 32-byte Iroh secret. - /// - generation: A positive PostgreSQL-compatible identity generation. - /// - Throws: ``CmxIrohIdentityRepositoryError/invalidGeneration`` for an - /// out-of-range generation. - public init(secretKey: CmxIrohSecretKey, generation: Int) throws { - guard (1...Int(Int32.max)).contains(generation) else { - throw CmxIrohIdentityRepositoryError.invalidGeneration - } - self.secretKey = secretKey - self.generation = generation - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityRepository.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityRepository.swift deleted file mode 100644 index 5768bf16..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityRepository.swift +++ /dev/null @@ -1,129 +0,0 @@ -import CryptoKit -public import Foundation - -/// Reconciles stable Iroh identity with reinstall and account-switch policy. -public actor CmxIrohIdentityRepository { - private static let installMarkerKey = "cmux.iroh.identity.install-marker.v1" - private static let activeScopeKey = "cmux.iroh.identity.active-scope.v1" - private static let recordVersion: UInt8 = 1 - - private let secureStore: any CmxIrohSecureIdentityStoring - private let installState: any CmxIrohInstallStateStoring - private let randomBytes: @Sendable () throws -> Data - private let marker: @Sendable () -> String - - /// Creates an identity repository with injectable persistence and entropy. - public init( - secureStore: any CmxIrohSecureIdentityStoring = CmxIrohKeychainIdentityStore(), - installState: any CmxIrohInstallStateStoring = CmxIrohUserDefaultsInstallStateStore(), - randomBytes: @escaping @Sendable () throws -> Data = { - try CmxIrohKeychainIdentityStore.randomSecretBytes() - }, - marker: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() } - ) { - self.secureStore = secureStore - self.installState = installState - self.randomBytes = randomBytes - self.marker = marker - } - - /// Returns stable material for the exact account and app instance. - /// - /// A missing install marker removes Keychain material that survived an app - /// uninstall. Changing account scope removes the prior account key before - /// creating a new EndpointID. - public func identity(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial { - let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID) - if let encoded = try secureStore.read(account: scope) { - return try Self.decode(encoded) - } - return try create(scope: scope, generation: 1) - } - - /// Replaces the active account key and increments its identity generation. - public func rotate(accountID: String, appInstanceID: String) throws -> CmxIrohIdentityMaterial { - let scope = try prepareScope(accountID: accountID, appInstanceID: appInstanceID) - let current = try secureStore.read(account: scope).map(Self.decode) - let generation = try current.map { material in - guard material.generation < Int(Int32.max) else { - throw CmxIrohIdentityRepositoryError.invalidGeneration - } - return material.generation + 1 - } ?? 1 - return try create(scope: scope, generation: generation) - } - - /// Removes all endpoint identity when signing out or locally revoking it. - public func deactivate() throws { - try secureStore.deleteAll() - installState.set(nil, forKey: Self.activeScopeKey) - } - - private func prepareScope(accountID: String, appInstanceID: String) throws -> String { - guard !accountID.isEmpty, - accountID.utf8.count <= 1_024, - !appInstanceID.isEmpty, - appInstanceID.utf8.count <= 256 else { - throw CmxIrohIdentityRepositoryError.invalidScope - } - var clearedSecureStore = false - if installState.string(forKey: Self.installMarkerKey) == nil { - try secureStore.deleteAll() - clearedSecureStore = true - installState.set(nil, forKey: Self.activeScopeKey) - installState.set(marker(), forKey: Self.installMarkerKey) - } - let scope = Self.scope(accountID: accountID, appInstanceID: appInstanceID) - if installState.string(forKey: Self.activeScopeKey) != scope { - if !clearedSecureStore { - try secureStore.deleteAll() - } - installState.set(scope, forKey: Self.activeScopeKey) - } - return scope - } - - private func create(scope: String, generation: Int) throws -> CmxIrohIdentityMaterial { - let secretKey = try CmxIrohSecretKey(bytes: randomBytes()) - let material = try CmxIrohIdentityMaterial(secretKey: secretKey, generation: generation) - try secureStore.write(Self.encode(material), account: scope) - return material - } - - private static func scope(accountID: String, appInstanceID: String) -> String { - let transcript = Data( - "cmux/iroh/identity-scope/v1\0\(accountID)\0\(appInstanceID)".utf8 - ) - return SHA256.hash(data: transcript).map { String(format: "%02x", $0) }.joined() - } - - private static func encode(_ material: CmxIrohIdentityMaterial) -> Data { - var bytes = [recordVersion] - let generation = UInt32(material.generation) - bytes.append(UInt8((generation >> 24) & 0xff)) - bytes.append(UInt8((generation >> 16) & 0xff)) - bytes.append(UInt8((generation >> 8) & 0xff)) - bytes.append(UInt8(generation & 0xff)) - bytes.append(contentsOf: material.secretKey.bytes) - return Data(bytes) - } - - private static func decode(_ data: Data) throws -> CmxIrohIdentityMaterial { - let bytes = [UInt8](data) - guard bytes.count == 37, bytes[0] == recordVersion else { - throw CmxIrohIdentityRepositoryError.corruptRecord - } - let generation = UInt32(bytes[1]) << 24 - | UInt32(bytes[2]) << 16 - | UInt32(bytes[3]) << 8 - | UInt32(bytes[4]) - guard generation > 0, generation <= UInt32(Int32.max) else { - throw CmxIrohIdentityRepositoryError.corruptRecord - } - let secretKey = try CmxIrohSecretKey(bytes: Data(bytes[5...])) - return try CmxIrohIdentityMaterial( - secretKey: secretKey, - generation: Int(generation) - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityRepositoryError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityRepositoryError.swift deleted file mode 100644 index 9cf89e1b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohIdentityRepositoryError.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// Failures while reconciling device-local Iroh identity state. -public enum CmxIrohIdentityRepositoryError: Error, Equatable, Sendable { - /// The account or app-instance identifier is empty or too large. - case invalidScope - - /// Stored identity bytes do not match the versioned record contract. - case corruptRecord - - /// The identity generation is zero, exhausted, or database-incompatible. - case invalidGeneration - - /// Secure random generation failed with the platform status code. - case randomGenerationFailed(Int32) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift deleted file mode 100644 index 350f413d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInboundStream.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// A peer-created unidirectional stream after its lane header is removed. -public struct CmxIrohInboundStream: Sendable { - /// The declared server-event or artifact lane. - public let lane: CmxIrohLane - - /// The readable application payload after the consumed header. - public let receiveStream: any CmxIrohReceiveStream - - /// Creates a decoded inbound stream. - /// - /// - Parameters: - /// - lane: The peer-declared application lane. - /// - receiveStream: The stream with any over-read bytes preserved. - public init(lane: CmxIrohLane, receiveStream: any CmxIrohReceiveStream) { - self.lane = lane - self.receiveStream = receiveStream - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInstallStateStoring.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInstallStateStoring.swift deleted file mode 100644 index b17305a7..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohInstallStateStoring.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// Non-secret installation state used to detect reinstall and account changes. -public protocol CmxIrohInstallStateStoring: Sendable { - /// Returns the value for a repository-owned state key. - func string(forKey key: String) -> String? - - /// Sets or clears the value for a repository-owned state key. - func set(_ value: String?, forKey key: String) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainCredentialStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainCredentialStore.swift deleted file mode 100644 index ee261084..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainCredentialStore.swift +++ /dev/null @@ -1,124 +0,0 @@ -public import Foundation -import Security - -/// Device-only Keychain storage for Iroh relay capabilities. -public actor CmxIrohKeychainCredentialStore: CmxIrohSecureCredentialStoring { - private let service: String - - /// Creates a Keychain store isolated by service name. - /// - /// - Parameter service: The generic-password service identifier. - public init(service: String = "com.cmuxterm.iroh.relay-credentials.v1") { - self.service = service - } - - /// Loads one opaque-scope capability from Keychain. - /// - /// - Parameter account: The repository-derived scope. - /// - Returns: The stored capability, or `nil` when none exists. - /// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails. - public func read(account: String) throws -> Data? { - var query = baseQuery(account: account) - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - return nil - } - guard status == errSecSuccess, let data = result as? Data else { - throw CmxIrohKeychainCredentialStoreError(status: status) - } - return data - } - - /// Upserts one opaque-scope capability with the requested data protection. - /// - /// - Parameters: - /// - data: The encoded capability. - /// - account: The repository-derived scope. - /// - accessibility: The required data-protection policy. - /// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails. - public func write( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility - ) throws { - let query = baseQuery(account: account) - let attributes: [String: Any] = [ - kSecValueData as String: data, - kSecAttrAccessible as String: secAccessibility(accessibility), - ] - let updateStatus = SecItemUpdate( - query as CFDictionary, - attributes as CFDictionary - ) - if updateStatus == errSecSuccess { - return - } - guard updateStatus == errSecItemNotFound else { - throw CmxIrohKeychainCredentialStoreError(status: updateStatus) - } - - var insert = query - attributes.forEach { insert[$0.key] = $0.value } - let addStatus = SecItemAdd(insert as CFDictionary, nil) - if addStatus == errSecSuccess { - return - } - guard addStatus == errSecDuplicateItem else { - throw CmxIrohKeychainCredentialStoreError(status: addStatus) - } - let retryStatus = SecItemUpdate( - query as CFDictionary, - attributes as CFDictionary - ) - guard retryStatus == errSecSuccess else { - throw CmxIrohKeychainCredentialStoreError(status: retryStatus) - } - } - - /// Removes one opaque-scope capability from Keychain. - /// - /// - Parameter account: The repository-derived scope. - /// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails. - public func delete(account: String) throws { - try delete(query: baseQuery(account: account)) - } - - /// Removes every relay capability owned by this Keychain service. - /// - /// - Throws: ``CmxIrohKeychainCredentialStoreError`` when Keychain fails. - public func deleteAll() throws { - try delete(query: baseQuery()) - } - - private func baseQuery(account: String? = nil) -> [String: Any] { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrSynchronizable as String: false, - kSecUseDataProtectionKeychain as String: true, - ] - if let account { - query[kSecAttrAccount as String] = account - } - return query - } - - private func secAccessibility( - _ accessibility: CmxIrohSecureCredentialAccessibility - ) -> CFString { - switch accessibility { - case .afterFirstUnlockThisDeviceOnly: - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - } - } - - private func delete(query: [String: Any]) throws { - let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw CmxIrohKeychainCredentialStoreError(status: status) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainCredentialStoreError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainCredentialStoreError.swift deleted file mode 100644 index 00d16e23..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainCredentialStoreError.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// A Keychain failure that never contains credential material. -public struct CmxIrohKeychainCredentialStoreError: Error, Equatable, Sendable { - /// The Security framework status code. - public let status: Int32 - - /// Creates a status-only Keychain error. - /// - /// - Parameter status: The Security framework status code. - public init(status: Int32) { - self.status = status - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainIdentityStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainIdentityStore.swift deleted file mode 100644 index ab41df4c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainIdentityStore.swift +++ /dev/null @@ -1,91 +0,0 @@ -public import Foundation -import Security - -/// Device-only Keychain storage for Iroh EndpointID secret material. -public final class CmxIrohKeychainIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable { - private let service: String - - /// Creates a Keychain store isolated by service name. - /// - /// - Parameter service: The generic-password service identifier. - public init(service: String = "com.cmuxterm.iroh.endpoint-identity.v1") { - self.service = service - } - - public func read(account: String) throws -> Data? { - var query = baseQuery(account: account) - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - return nil - } - guard status == errSecSuccess, let data = result as? Data else { - throw CmxIrohKeychainIdentityStoreError(status: status) - } - return data - } - - public func write(_ data: Data, account: String) throws { - let query = baseQuery(account: account) - let updateStatus = SecItemUpdate( - query as CFDictionary, - [kSecValueData as String: data] as CFDictionary - ) - if updateStatus == errSecSuccess { - return - } - guard updateStatus == errSecItemNotFound else { - throw CmxIrohKeychainIdentityStoreError(status: updateStatus) - } - var insert = query - insert[kSecValueData as String] = data - insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let addStatus = SecItemAdd(insert as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw CmxIrohKeychainIdentityStoreError(status: addStatus) - } - } - - public func delete(account: String) throws { - try delete(query: baseQuery(account: account)) - } - - public func deleteAll() throws { - try delete(query: baseQuery()) - } - - /// Generates one Ed25519 secret using Security.framework. - public static func randomSecretBytes() throws -> Data { - let count = 32 - var data = Data(count: count) - let status = data.withUnsafeMutableBytes { bytes in - SecRandomCopyBytes(kSecRandomDefault, count, bytes.baseAddress!) - } - guard status == errSecSuccess else { - throw CmxIrohIdentityRepositoryError.randomGenerationFailed(status) - } - return data - } - - private func baseQuery(account: String? = nil) -> [String: Any] { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrSynchronizable as String: false, - kSecUseDataProtectionKeychain as String: true, - ] - if let account { - query[kSecAttrAccount as String] = account - } - return query - } - - private func delete(query: [String: Any]) throws { - let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw CmxIrohKeychainIdentityStoreError(status: status) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainIdentityStoreError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainIdentityStoreError.swift deleted file mode 100644 index 8543ec58..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohKeychainIdentityStoreError.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// Keychain failures surfaced without exposing identity material. -public struct CmxIrohKeychainIdentityStoreError: Error, Equatable, Sendable { - /// The Security framework status code. - public let status: Int32 - - /// Creates a status-only Keychain error. - public init(status: Int32) { - self.status = status - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANAdvertisement.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANAdvertisement.swift deleted file mode 100644 index 7dbdca6a..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANAdvertisement.swift +++ /dev/null @@ -1,112 +0,0 @@ -public import Foundation - -/// One interface-scoped, privacy-preserving DNS-SD registration. -public struct CmxIrohLANAdvertisement: Equatable, Sendable { - public static let serviceType = "_cmux-iroh._udp" - public static let domain = "local." - - /// The rotating account-private service instance name. - public let alias: String - /// Explicit opaque SRV target. The system's descriptive hostname is never used. - public let hostTarget: String - public let interfaceIndex: UInt32 - public let port: UInt16 - public let addresses: [CmxIrohLANSocketAddress] - public let txtRecord: Data - public let expiresAt: Date - - init( - alias: String, - hostTarget: String, - interfaceIndex: UInt32, - port: UInt16, - addresses: [CmxIrohLANSocketAddress], - txtRecord: Data, - expiresAt: Date - ) throws { - guard CmxIrohLANRendezvousAliasGenerator.isCanonicalAlias(alias), - hostTarget == "h-\(alias).local.", - hostTarget.utf8.count <= 253, - interfaceIndex != 0, - port != 0, - !addresses.isEmpty, - addresses.count <= CmxIrohLANTXTRecord.maximumAddressCount, - addresses.contains(where: { $0.port == port }), - txtRecord.count <= CmxIrohLANTXTRecord.maximumEncodedSize, - expiresAt.timeIntervalSince1970.isFinite else { - throw CmxIrohLANDiscoveryError.invalidAdvertisement - } - self.alias = alias - self.hostTarget = hostTarget - self.interfaceIndex = interfaceIndex - self.port = port - self.addresses = addresses - self.txtRecord = txtRecord - self.expiresAt = expiresAt - } -} - -/// Builds only interface-local advertisements from the endpoint's raw address view. -public struct CmxIrohLANAdvertisementBuilder: Sendable { - public static let maximumRawAddressCount = 32 - public static let maximumInterfaceCount = 8 - - public init() {} - - public func advertisements( - rendezvous: CmxIrohLANRendezvous, - binding: CmxIrohBrokerBindingMetadata, - directAddresses: [String], - interfaces: [CmxIrohLANInterfaceAddress], - at date: Date - ) throws -> [CmxIrohLANAdvertisement] { - guard directAddresses.count <= Self.maximumRawAddressCount, - interfaces.count <= 64 else { - throw CmxIrohLANDiscoveryError.invalidAdvertisement - } - let generator = try CmxIrohLANRendezvousAliasGenerator(rendezvous: rendezvous) - let alias = try generator.alias(for: binding, at: date) - let epoch = try CmxIrohLANRendezvousAliasGenerator.epoch(for: date) - let expiryValue = (TimeInterval(epoch) + 1) * CmxIrohLANRendezvousAliasGenerator.rotationInterval - guard expiryValue.isFinite else { throw CmxIrohLANDiscoveryError.invalidAdvertisement } - let expiresAt = Date(timeIntervalSince1970: expiryValue) - - let eligible = Array(Set(interfaces)) - var byInterface: [UInt32: Set<CmxIrohLANSocketAddress>] = [:] - for raw in directAddresses { - if let wildcard = CmxIrohLANSocketAddress.wildcard(raw) { - for interface in eligible where interface.family == wildcard.family { - let value = CmxIrohLANSocketAddress.canonicalValue( - ipAddress: interface.ipAddress, - port: wildcard.port - ) - if let socket = try? CmxIrohLANSocketAddress(value) { - byInterface[interface.interfaceIndex, default: []].insert(socket) - } - } - continue - } - guard let socket = try? CmxIrohLANSocketAddress(raw) else { continue } - for interface in eligible where interface.ipAddress == socket.ipAddress { - byInterface[interface.interfaceIndex, default: []].insert(socket) - } - } - guard byInterface.count <= Self.maximumInterfaceCount else { - throw CmxIrohLANDiscoveryError.invalidAdvertisement - } - - return try byInterface.sorted(by: { $0.key < $1.key }).map { interfaceIndex, values in - let addresses = values.sorted { $0.value < $1.value } - let txt = try CmxIrohLANTXTRecord(epoch: epoch, addresses: addresses).encoded() - return try CmxIrohLANAdvertisement( - alias: alias, - hostTarget: "h-\(alias).local.", - interfaceIndex: interfaceIndex, - port: addresses[0].port, - addresses: addresses, - txtRecord: txt, - expiresAt: expiresAt - ) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANDiscoveryError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANDiscoveryError.swift deleted file mode 100644 index 341d34c3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANDiscoveryError.swift +++ /dev/null @@ -1,10 +0,0 @@ -public enum CmxIrohLANDiscoveryError: Error, Equatable, Sendable { - case invalidSocketAddress - case invalidInterface - case invalidAdvertisement - case invalidTXTRecord - case staleAdvertisement - case ambiguousBinding - case policyDenied - case serviceFailure(Int32) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANDiscoveryResolver.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANDiscoveryResolver.swift deleted file mode 100644 index 11dd9032..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANDiscoveryResolver.swift +++ /dev/null @@ -1,132 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// A raw DNS-SD resolve result. Every field remains untrusted until resolved below. -public struct CmxIrohBonjourResolvedService: Equatable, Sendable { - public let serviceName: String - public let hostTarget: String - public let interfaceIndex: UInt32 - public let port: UInt16 - public let txtRecord: Data - - public init( - serviceName: String, - hostTarget: String, - interfaceIndex: UInt32, - port: UInt16, - txtRecord: Data - ) { - self.serviceName = serviceName - self.hostTarget = hostTarget - self.interfaceIndex = interfaceIndex - self.port = port - self.txtRecord = txtRecord - } -} - -/// An authenticated binding plus short-lived local reachability only. -public struct CmxIrohLANResolvedPeer: Equatable, Sendable { - public let binding: CmxIrohBrokerBindingMetadata - public let interfaceIndex: UInt32 - public let pathGeneration: UInt64 - public let networkProfile: CmxIrohNetworkProfileKey - public let pathHints: [CmxIrohPathHint] -} - -/// Maps an opaque service only to one already-authenticated same-account Mac. -public struct CmxIrohLANDiscoveryResolver: Sendable { - public static let maximumHintTTL: TimeInterval = 60 - - public init() {} - - public func resolve( - _ service: CmxIrohBonjourResolvedService, - rendezvous: CmxIrohLANRendezvous, - authenticatedBindings: [CmxIrohBrokerBindingMetadata], - expectedMacDeviceID: String, - expectedEndpointID: CmxIrohPeerIdentity? = nil, - networkPathSnapshot: CmxIrohNetworkPathSnapshot, - interfaces: [CmxIrohLANInterfaceAddress], - at date: Date - ) throws -> CmxIrohLANResolvedPeer { - guard CmxIrohLANRendezvousAliasGenerator.isCanonicalAlias(service.serviceName), - service.hostTarget == "h-\(service.serviceName).local.", - service.interfaceIndex != 0, - service.port != 0, - service.txtRecord.count <= CmxIrohLANTXTRecord.maximumEncodedSize else { - throw CmxIrohLANDiscoveryError.invalidAdvertisement - } - let candidates = authenticatedBindings.filter { binding in - binding.platform == .mac - && cmxCanonicalDeviceID(binding.deviceID) - == cmxCanonicalDeviceID(expectedMacDeviceID) - && (expectedEndpointID == nil || binding.endpointID == expectedEndpointID) - } - guard !candidates.isEmpty, - candidates.count <= CmxIrohDiscoveryResponse.maximumBindingCount else { - throw CmxIrohLANDiscoveryError.ambiguousBinding - } - let aliasGenerator = try CmxIrohLANRendezvousAliasGenerator(rendezvous: rendezvous) - guard let binding = try aliasGenerator.binding( - matching: service.serviceName, - among: candidates, - at: date - ) else { - throw CmxIrohLANDiscoveryError.ambiguousBinding - } - let txt = try CmxIrohLANTXTRecord(encoded: service.txtRecord) - let currentEpoch = try CmxIrohLANRendezvousAliasGenerator.epoch(for: date) - guard txt.epoch >= currentEpoch - 1, - txt.epoch <= currentEpoch + 1, - try aliasGenerator.alias(for: binding, epoch: txt.epoch) == service.serviceName, - txt.addresses.first?.port == service.port else { - throw CmxIrohLANDiscoveryError.staleAdvertisement - } - - let matchingInterfaces = interfaces.filter { - $0.interfaceIndex == service.interfaceIndex - } - guard !matchingInterfaces.isEmpty, - txt.addresses.allSatisfy({ address in - let owningInterfaces = Set( - interfaces.lazy - .filter { $0.contains(address) } - .map(\.interfaceIndex) - ) - return owningInterfaces == [service.interfaceIndex] - }) else { - throw CmxIrohLANDiscoveryError.invalidInterface - } - let profile = try CmxIrohLANNetworkProfileGenerator(rendezvous: rendezvous).profile( - interfaceIndex: service.interfaceIndex, - pathGeneration: networkPathSnapshot.generation - ) - let epochLimit = Date( - timeIntervalSince1970: (TimeInterval(txt.epoch) + 2) - * CmxIrohLANRendezvousAliasGenerator.rotationInterval - ) - let expiresAt = min( - date.addingTimeInterval(Self.maximumHintTTL), - epochLimit - ) - guard expiresAt > date else { throw CmxIrohLANDiscoveryError.staleAdvertisement } - let hints = try txt.addresses.map { address in - try CmxIrohPathHint( - kind: .directAddress, - value: address.value, - source: .lan, - privacyScope: .localNetwork, - observedAt: date, - expiresAt: expiresAt, - networkProfile: profile - ) - } - return CmxIrohLANResolvedPeer( - binding: binding, - interfaceIndex: service.interfaceIndex, - pathGeneration: networkPathSnapshot.generation, - networkProfile: profile, - pathHints: hints - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANHostPublisher.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANHostPublisher.swift deleted file mode 100644 index b9d4c4b5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANHostPublisher.swift +++ /dev/null @@ -1,188 +0,0 @@ -public import Foundation - -public protocol CmxIrohLANClock: Sendable { - func now() -> Date - func sleep(for interval: TimeInterval) async throws -} - -public struct CmxIrohLANSystemClock: CmxIrohLANClock { - public init() {} - - public func now() -> Date { Date() } - - public func sleep(for interval: TimeInterval) async throws { - guard interval.isFinite, interval > 0 else { return } - let milliseconds = Int64(min(interval, 10 * 60) * 1_000) - try await ContinuousClock().sleep(for: .milliseconds(milliseconds)) - } -} - -public enum CmxIrohLANHostPublisherState: Equatable, Sendable { - case inactive - case active - case unavailable - case policyDenied -} - -/// Owns rotation and replacement of one host's account-private advertisements. -public actor CmxIrohLANHostPublisher { - public typealias DirectAddressProvider = @Sendable () async -> [String] - - private struct Context: Sendable { - let rendezvous: CmxIrohLANRendezvous - let binding: CmxIrohBrokerBindingMetadata - let directAddresses: DirectAddressProvider - } - - private let publisher: any CmxIrohBonjourPublishing - private let interfaces: any CmxIrohLANInterfaceSnapshotProviding - private let builder: CmxIrohLANAdvertisementBuilder - private let clock: any CmxIrohLANClock - private var context: Context? - private var revision: UInt64 = 0 - private var rotationTask: Task<Void, Never>? - private var eventTask: Task<Void, Never>? - private var state: CmxIrohLANHostPublisherState = .inactive - - public init( - publisher: any CmxIrohBonjourPublishing = CmxIrohSystemBonjourPublisher(), - interfaces: any CmxIrohLANInterfaceSnapshotProviding = CmxIrohSystemLANInterfaceSnapshotProvider(), - builder: CmxIrohLANAdvertisementBuilder = CmxIrohLANAdvertisementBuilder(), - clock: any CmxIrohLANClock = CmxIrohLANSystemClock() - ) { - self.publisher = publisher - self.interfaces = interfaces - self.builder = builder - self.clock = clock - } - - public func snapshot() -> CmxIrohLANHostPublisherState { state } - - public func activate( - rendezvous: CmxIrohLANRendezvous, - binding: CmxIrohBrokerBindingMetadata, - directAddresses: @escaping DirectAddressProvider - ) async { - revision &+= 1 - let currentRevision = revision - rotationTask?.cancel() - context = Context( - rendezvous: rendezvous, - binding: binding, - directAddresses: directAddresses - ) - startEventObservationIfNeeded() - await refresh(revision: currentRevision) - guard revision == currentRevision, state != .policyDenied else { return } - rotationTask = Task { [weak self] in - await self?.rotate(revision: currentRevision) - } - } - - /// Re-reads endpoint and interface addresses after a network change. - public func refresh() async { - await refresh(revision: revision) - } - - /// Retries a policy-blocked publication after the user may have changed - /// Local Network permission. A stopped or inactive listener stays inert. - public func permissionMayHaveChanged() async { - guard state == .policyDenied, context != nil else { return } - let currentRevision = revision - state = .unavailable - startEventObservationIfNeeded() - await refresh(revision: currentRevision) - guard revision == currentRevision, - state != .policyDenied, - context != nil else { return } - rotationTask?.cancel() - rotationTask = Task { [weak self] in - await self?.rotate(revision: currentRevision) - } - } - - public func stop() async { - revision &+= 1 - context = nil - rotationTask?.cancel() - rotationTask = nil - eventTask?.cancel() - eventTask = nil - await publisher.stop() - state = .inactive - } - - private func rotate(revision expectedRevision: UInt64) async { - while expectedRevision == revision, !Task.isCancelled { - let now = clock.now() - guard let epoch = try? CmxIrohLANRendezvousAliasGenerator.epoch(for: now) else { - state = .unavailable - return - } - let nextEpoch = Date( - timeIntervalSince1970: (TimeInterval(epoch) + 1) - * CmxIrohLANRendezvousAliasGenerator.rotationInterval - ) - do { - try await clock.sleep(for: max(0.001, nextEpoch.timeIntervalSince(now))) - try Task.checkCancellation() - } catch { - return - } - await refresh(revision: expectedRevision) - } - } - - private func refresh(revision expectedRevision: UInt64) async { - guard expectedRevision == revision, - state != .policyDenied, - let context else { return } - let directAddresses = await context.directAddresses() - guard expectedRevision == revision, !Task.isCancelled else { return } - do { - let advertisements = try builder.advertisements( - rendezvous: context.rendezvous, - binding: context.binding, - directAddresses: directAddresses, - interfaces: try interfaces.interfaceAddresses(), - at: clock.now() - ) - try await publisher.replace(with: advertisements) - guard expectedRevision == revision else { return } - state = advertisements.isEmpty ? .unavailable : .active - } catch CmxIrohLANDiscoveryError.policyDenied { - state = .policyDenied - rotationTask?.cancel() - rotationTask = nil - } catch is CancellationError { - return - } catch { - if expectedRevision == revision { state = .unavailable } - } - } - - private func startEventObservationIfNeeded() { - guard eventTask == nil else { return } - eventTask = Task { [weak self, publisher] in - let events = await publisher.events() - for await event in events { - guard !Task.isCancelled else { return } - await self?.handle(event) - } - } - } - - private func handle(_ event: CmxIrohBonjourPublisherEvent) { - guard context != nil else { return } - switch event { - case .registered: - break - case .policyDenied: - state = .policyDenied - rotationTask?.cancel() - rotationTask = nil - case .failed: - if state != .policyDenied { state = .unavailable } - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANInterfaceAddress.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANInterfaceAddress.swift deleted file mode 100644 index fff6507e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANInterfaceAddress.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Darwin -import Foundation - -/// One eligible local-interface address and its subnet mask. -public struct CmxIrohLANInterfaceAddress: Equatable, Hashable, Sendable { - public let interfaceIndex: UInt32 - public let ipAddress: String - public let family: CmxIrohLANSocketAddress.Family - - let addressBytes: [UInt8] - let netmaskBytes: [UInt8] - - public init( - interfaceIndex: UInt32, - ipAddress: String, - netmask: String - ) throws { - guard interfaceIndex != 0 else { throw CmxIrohLANDiscoveryError.invalidInterface } - let address = try CmxIrohLANSocketAddress( - CmxIrohLANSocketAddress.canonicalValue(ipAddress: ipAddress, port: 1) - ) - let mask = try Self.parseMask(netmask, family: address.family) - guard mask.count == address.addressBytes.count, - Self.isContiguousMask(mask) else { - throw CmxIrohLANDiscoveryError.invalidInterface - } - self.interfaceIndex = interfaceIndex - self.ipAddress = address.ipAddress - family = address.family - addressBytes = address.addressBytes - netmaskBytes = mask - } - - /// Whether a remote address is on-link for this exact DNS-SD interface. - public func contains(_ address: CmxIrohLANSocketAddress) -> Bool { - guard address.family == family, - address.addressBytes.count == addressBytes.count else { return false } - return zip(address.addressBytes, zip(addressBytes, netmaskBytes)).allSatisfy { - ($0.0 & $0.1.1) == ($0.1.0 & $0.1.1) - } - } - - private static func parseMask( - _ value: String, - family: CmxIrohLANSocketAddress.Family - ) throws -> [UInt8] { - switch family { - case .ipv4: - var address = in_addr() - guard value.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { - throw CmxIrohLANDiscoveryError.invalidInterface - } - return withUnsafeBytes(of: &address) { Array($0) } - case .ipv6: - var address = in6_addr() - guard value.withCString({ inet_pton(AF_INET6, $0, &address) }) == 1 else { - throw CmxIrohLANDiscoveryError.invalidInterface - } - return withUnsafeBytes(of: &address) { Array($0) } - } - } - - private static func isContiguousMask(_ bytes: [UInt8]) -> Bool { - var sawZero = false - for byte in bytes { - for bit in (0 ..< 8).reversed() { - let set = byte & (1 << bit) != 0 - if sawZero && set { return false } - if !set { sawZero = true } - } - } - return bytes.contains(where: { $0 != 0 }) - } -} - -/// Supplies current eligible multicast-capable LAN interfaces. -public protocol CmxIrohLANInterfaceSnapshotProviding: Sendable { - func interfaceAddresses() throws -> [CmxIrohLANInterfaceAddress] -} - -/// Reads user-facing Wi-Fi, Ethernet, VLAN, and bonded-link interfaces without -/// exposing their names. Generic VPN and VM reachability stays in explicit -/// private-network Iroh hints and is never advertised through Bonjour. -public struct CmxIrohSystemLANInterfaceSnapshotProvider: CmxIrohLANInterfaceSnapshotProviding { - public init() {} - - public func interfaceAddresses() throws -> [CmxIrohLANInterfaceAddress] { - var first: UnsafeMutablePointer<ifaddrs>? - guard getifaddrs(&first) == 0 else { - throw CmxIrohLANDiscoveryError.invalidInterface - } - defer { freeifaddrs(first) } - - var result: [CmxIrohLANInterfaceAddress] = [] - var cursor = first - while let current = cursor?.pointee { - defer { cursor = current.ifa_next } - let flags = Int32(current.ifa_flags) - guard flags & IFF_UP != 0, - flags & IFF_RUNNING != 0, - flags & IFF_MULTICAST != 0, - flags & IFF_LOOPBACK == 0, - flags & IFF_POINTOPOINT == 0, - let addressPointer = current.ifa_addr, - let maskPointer = current.ifa_netmask else { continue } - let family = Int32(addressPointer.pointee.sa_family) - guard family == AF_INET || family == AF_INET6 else { continue } - let name = String(cString: current.ifa_name) - guard Self.isEligibleInterfaceName(name) else { continue } - let index = if_nametoindex(name) - guard index != 0, - let address = Self.numericAddress(addressPointer), - let mask = Self.numericAddress(maskPointer), - let value = try? CmxIrohLANInterfaceAddress( - interfaceIndex: index, - ipAddress: address, - netmask: mask - ) else { continue } - result.append(value) - } - return Array(Set(result)).sorted { - if $0.interfaceIndex != $1.interfaceIndex { - return $0.interfaceIndex < $1.interfaceIndex - } - return $0.ipAddress < $1.ipAddress - } - } - - static func isEligibleInterfaceName(_ value: String) -> Bool { - // Darwin assigns enN to user Wi-Fi/Ethernet hardware, vlanN to - // configured 802.1Q links, and bondN to configured link aggregates. - // A narrow allowlist avoids publishing into multicast-capable VM, - // container, tunnel, peer-to-peer, and ambiguous bridge interfaces. - for prefix in ["en", "vlan", "bond"] where value.hasPrefix(prefix) { - let suffix = value.dropFirst(prefix.count) - if !suffix.isEmpty, - suffix.utf8.allSatisfy({ (48 ... 57).contains($0) }) { return true } - } - return false - } - - private static func numericAddress(_ pointer: UnsafePointer<sockaddr>) -> String? { - var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - let result = getnameinfo( - pointer, - socklen_t(pointer.pointee.sa_len), - &host, - socklen_t(host.count), - nil, - 0, - NI_NUMERICHOST - ) - guard result == 0 else { return nil } - let value = String( - decoding: host.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, - as: UTF8.self - ) - return value.split(separator: "%", maxSplits: 1).first.map(String.init) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANNetworkProfileGenerator.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANNetworkProfileGenerator.swift deleted file mode 100644 index e131671c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANNetworkProfileGenerator.swift +++ /dev/null @@ -1,36 +0,0 @@ -import CryptoKit -public import CMUXMobileCore -import Foundation - -/// Derives account-private local profile IDs without disclosing interface names. -public struct CmxIrohLANNetworkProfileGenerator: Sendable { - private let keyBytes: [UInt8] - private let rendezvousGeneration: Int - - public init(rendezvous: CmxIrohLANRendezvous) throws { - let padding = String(repeating: "=", count: (4 - rendezvous.key.count % 4) % 4) - let standard = rendezvous.key - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - guard let data = Data(base64Encoded: standard), data.count == 32 else { - throw CmxIrohLANRendezvousAliasError.invalidKey - } - keyBytes = Array(data) - rendezvousGeneration = rendezvous.generation - } - - public func profile( - interfaceIndex: UInt32, - pathGeneration: UInt64 - ) throws -> CmxIrohNetworkProfileKey { - guard interfaceIndex != 0 else { throw CmxIrohLANDiscoveryError.invalidInterface } - let transcript = Data( - "cmux/iroh/lan-network-profile/v1\0\(rendezvousGeneration)\0\(pathGeneration)\0\(interfaceIndex)".utf8 - ) - let key = SymmetricKey(data: keyBytes) - let digest = HMAC<SHA256>.authenticationCode(for: transcript, using: key) - .map { String(format: "%02x", $0) } - .joined() - return try CmxIrohNetworkProfileKey(source: .lan, profileID: digest) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANPeerDiscovery.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANPeerDiscovery.swift deleted file mode 100644 index fec379a1..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANPeerDiscovery.swift +++ /dev/null @@ -1,308 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -public enum CmxIrohLANPeerDiscoveryOutcome: Equatable, Sendable { - case found([CmxIrohLANResolvedPeer]) - case notFound - case policyDenied -} - -private actor CmxIrohLANChangeSignal { - private var generation: UInt64 = 0 - private var observers: [UUID: AsyncStream<Void>.Continuation] = [:] - - func snapshot() -> UInt64 { generation } - - func events(after expectedGeneration: UInt64) -> AsyncStream<Void> { - let id = UUID() - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - guard generation == expectedGeneration else { - continuation.yield(()) - continuation.finish() - return - } - observers[id] = continuation - continuation.onTermination = { [weak self] _ in - Task { await self?.remove(id) } - } - } - } - - func publish() { - generation &+= 1 - for observer in observers.values { observer.yield(()) } - } - - private func remove(_ id: UUID) { - observers.removeValue(forKey: id) - } -} - -/// Lazily browses for an already-known peer and owns generation-scoped profiles. -public actor CmxIrohLANPeerDiscovery { - public typealias BrowserFactory = @Sendable () -> any CmxIrohBonjourBrowsing - public typealias NetworkPathProvider = @Sendable () async -> CmxIrohNetworkPathSnapshot - public typealias ProfileAuthorizer = @Sendable ( - _ profile: CmxIrohNetworkProfileKey, - _ pathGeneration: UInt64, - _ interfaceIndex: UInt32 - ) async -> Bool - public typealias ProfileRevoker = @Sendable ( - _ profile: CmxIrohNetworkProfileKey, - _ pathGeneration: UInt64 - ) async -> Void - - private struct RequestKey: Hashable, Sendable { - let deviceID: String - let endpointID: CmxIrohPeerIdentity - } - - private struct RequestContext: Sendable { - let rendezvous: CmxIrohLANRendezvous - let bindings: [CmxIrohBrokerBindingMetadata] - let expectedDeviceID: String - let expectedEndpointID: CmxIrohPeerIdentity - let pathGeneration: UInt64 - } - - private struct ProfileGeneration: Hashable, Sendable { - let profile: CmxIrohNetworkProfileKey - let generation: UInt64 - } - - private let browserFactory: BrowserFactory - private let interfaces: any CmxIrohLANInterfaceSnapshotProviding - private let resolver: CmxIrohLANDiscoveryResolver - private let clock: any CmxIrohLANClock - private let networkPath: NetworkPathProvider - private let authorizeProfile: ProfileAuthorizer - private let revokeProfile: ProfileRevoker - private let changeSignal = CmxIrohLANChangeSignal() - private var browser: (any CmxIrohBonjourBrowsing)? - private var browserTask: Task<Void, Never>? - private var requests: [RequestKey: RequestContext] = [:] - private var services: [CmxIrohBonjourServiceID: CmxIrohBonjourResolvedService] = [:] - private var results: [RequestKey: [CmxIrohBonjourServiceID: CmxIrohLANResolvedPeer]] = [:] - private var permissionDenied = false - private var lifecycleRevision: UInt64 = 0 - - public init( - browserFactory: @escaping BrowserFactory = { CmxIrohSystemBonjourBrowser() }, - interfaces: any CmxIrohLANInterfaceSnapshotProviding = CmxIrohSystemLANInterfaceSnapshotProvider(), - resolver: CmxIrohLANDiscoveryResolver = CmxIrohLANDiscoveryResolver(), - clock: any CmxIrohLANClock = CmxIrohLANSystemClock(), - networkPath: @escaping NetworkPathProvider, - authorizeProfile: @escaping ProfileAuthorizer, - revokeProfile: @escaping ProfileRevoker - ) { - self.browserFactory = browserFactory - self.interfaces = interfaces - self.resolver = resolver - self.clock = clock - self.networkPath = networkPath - self.authorizeProfile = authorizeProfile - self.revokeProfile = revokeProfile - } - - /// Browses only after a reconnect for one cached, already-known Mac. - public func discover( - rendezvous: CmxIrohLANRendezvous, - authenticatedBindings: [CmxIrohBrokerBindingMetadata], - expectedMacDeviceID: String, - expectedEndpointID: CmxIrohPeerIdentity, - timeout: TimeInterval = 0.75 - ) async -> CmxIrohLANPeerDiscoveryOutcome { - guard authenticatedBindings.count <= CmxIrohDiscoveryResponse.maximumBindingCount else { - return .notFound - } - let path = await networkPath() - let key = RequestKey( - deviceID: cmxCanonicalDeviceID(expectedMacDeviceID), - endpointID: expectedEndpointID - ) - guard requests[key] != nil || requests.count < 32 else { return .notFound } - requests[key] = RequestContext( - rendezvous: rendezvous, - bindings: authenticatedBindings, - expectedDeviceID: expectedMacDeviceID, - expectedEndpointID: expectedEndpointID, - pathGeneration: path.generation - ) - await resolveKnownServices(for: key) - if let outcome = await currentOutcome(for: key) { return outcome } - guard !permissionDenied else { return .policyDenied } - let changeGeneration = await changeSignal.snapshot() - startBrowserIfNeeded() - guard timeout.isFinite, timeout > 0 else { return .notFound } - await waitForChangeOrTimeout(timeout, after: changeGeneration) - if let outcome = await currentOutcome(for: key) { return outcome } - return permissionDenied ? .policyDenied : .notFound - } - - /// Invalidates every result before a new path generation can authorize it. - public func pathDidChange() async { - lifecycleRevision &+= 1 - await clearResultsAndProfiles() - requests.removeAll(keepingCapacity: false) - services.removeAll(keepingCapacity: false) - permissionDenied = false - browserTask?.cancel() - browserTask = nil - await browser?.stop() - browser = nil - await changeSignal.publish() - } - - /// Allows a later explicit reconnect to retry after Local Network changes. - /// - /// Foregrounding never starts Bonjour. It only discards the sticky denial - /// and stale request state when the previous browser was policy-blocked. - public func permissionMayHaveChanged() async { - guard permissionDenied else { return } - lifecycleRevision &+= 1 - await clearResultsAndProfiles() - requests.removeAll(keepingCapacity: false) - services.removeAll(keepingCapacity: false) - permissionDenied = false - browserTask?.cancel() - browserTask = nil - await browser?.stop() - browser = nil - await changeSignal.publish() - } - - /// Clears account material and browsing on sign-out. - public func stop() async { - await pathDidChange() - } - - private func startBrowserIfNeeded() { - guard browser == nil else { return } - let browser = browserFactory() - self.browser = browser - let revision = lifecycleRevision - browserTask = Task { [weak self] in - let events = await browser.events() - for await event in events { - guard !Task.isCancelled else { return } - await self?.handle(event, revision: revision) - } - } - } - - private func handle( - _ event: CmxIrohBonjourBrowserEvent, - revision: UInt64 - ) async { - guard revision == lifecycleRevision else { return } - switch event { - case let .resolved(id, service): - guard CmxIrohLANRendezvousAliasGenerator.isCanonicalAlias(id.serviceName), - services.count < 64 || services[id] != nil else { return } - services[id] = service - for key in Array(requests.keys) { await resolve(service, id: id, for: key) } - case let .removed(id): - services.removeValue(forKey: id) - var removedPeers: [CmxIrohLANResolvedPeer] = [] - for key in Array(results.keys) { - if let removed = results[key]?.removeValue(forKey: id) { - removedPeers.append(removed) - } - if results[key]?.isEmpty == true { results.removeValue(forKey: key) } - } - let used = Set(results.values.flatMap { $0.values.map(\.networkProfile) }) - for peer in removedPeers where !used.contains(peer.networkProfile) { - await revokeProfile(peer.networkProfile, peer.pathGeneration) - } - case .policyDenied: - permissionDenied = true - await clearResultsAndProfiles() - browserTask?.cancel() - browserTask = nil - await browser?.stop() - browser = nil - case .failed: - break - } - guard revision == lifecycleRevision else { return } - await changeSignal.publish() - } - - private func resolveKnownServices(for key: RequestKey) async { - for (id, service) in Array(services) { await resolve(service, id: id, for: key) } - } - - private func resolve( - _ service: CmxIrohBonjourResolvedService, - id: CmxIrohBonjourServiceID, - for key: RequestKey - ) async { - guard let context = requests[key] else { return } - let currentPath = await networkPath() - guard currentPath.generation == context.pathGeneration, - let currentInterfaces = try? interfaces.interfaceAddresses(), - let peer = try? resolver.resolve( - service, - rendezvous: context.rendezvous, - authenticatedBindings: context.bindings, - expectedMacDeviceID: context.expectedDeviceID, - expectedEndpointID: context.expectedEndpointID, - networkPathSnapshot: currentPath, - interfaces: currentInterfaces, - at: clock.now() - ), - await authorizeProfile( - peer.networkProfile, - currentPath.generation, - peer.interfaceIndex - ) else { return } - let afterAuthorization = await networkPath() - guard afterAuthorization.generation == currentPath.generation, - afterAuthorization.activeNetworkProfiles.contains(peer.networkProfile) else { - await revokeProfile(peer.networkProfile, currentPath.generation) - return - } - results[key, default: [:]][id] = peer - } - - private func currentOutcome(for key: RequestKey) async -> CmxIrohLANPeerDiscoveryOutcome? { - guard let peers = results[key]?.values, !peers.isEmpty else { return nil } - let path = await networkPath() - let current = peers.filter { peer in - peer.pathGeneration == path.generation - && path.activeNetworkProfiles.contains(peer.networkProfile) - && peer.pathHints.allSatisfy { $0.isUsable(at: clock.now()) } - }.sorted { - if $0.interfaceIndex != $1.interfaceIndex { - return $0.interfaceIndex < $1.interfaceIndex - } - return $0.pathHints[0].value < $1.pathHints[0].value - } - return current.isEmpty ? nil : .found(current) - } - - private func clearResultsAndProfiles() async { - let profiles = Set(results.values.flatMap { $0.values.map { - ProfileGeneration(profile: $0.networkProfile, generation: $0.pathGeneration) - } }) - results.removeAll(keepingCapacity: false) - for value in profiles { - await revokeProfile(value.profile, value.generation) - } - } - - private func waitForChangeOrTimeout( - _ interval: TimeInterval, - after generation: UInt64 - ) async { - let changes = await changeSignal.events(after: generation) - await withTaskGroup(of: Void.self) { group in - group.addTask { - for await _ in changes { return } - } - group.addTask { [clock] in try? await clock.sleep(for: interval) } - _ = await group.next() - group.cancelAll() - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANRendezvousAliasError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANRendezvousAliasError.swift deleted file mode 100644 index 8a3726b7..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANRendezvousAliasError.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// Failures at the account-private LAN discovery alias boundary. -public enum CmxIrohLANRendezvousAliasError: Error, Equatable, Sendable { - case invalidKey - case invalidTimestamp - case unsupportedPlatform -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANRendezvousAliasGenerator.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANRendezvousAliasGenerator.swift deleted file mode 100644 index bd290a4a..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANRendezvousAliasGenerator.swift +++ /dev/null @@ -1,114 +0,0 @@ -import CryptoKit -public import Foundation - -/// Derives short-lived, account-private Bonjour names for verified Iroh bindings. -/// -/// A passive LAN observer sees only a rotating random-looking value. A signed-in -/// device with the current broker rendezvous key can map that value back to one -/// exact binding without advertising an EndpointID, account, device ID, build -/// tag, or display name over multicast DNS. -public struct CmxIrohLANRendezvousAliasGenerator: Sendable { - /// Bonjour aliases rotate every five minutes. - public static let rotationInterval: TimeInterval = 5 * 60 - - private let keyBytes: [UInt8] - private let generation: Int - - /// Creates a generator from authenticated broker rendezvous material. - public init(rendezvous: CmxIrohLANRendezvous) throws { - guard let keyData = Self.decodeBase64URL(rendezvous.key), - keyData.count == 32 else { - throw CmxIrohLANRendezvousAliasError.invalidKey - } - keyBytes = Array(keyData) - generation = rendezvous.generation - } - - /// Returns the current opaque alias for one exact broker binding. - public func alias( - for binding: CmxIrohBrokerBindingMetadata, - at date: Date - ) throws -> String { - try alias(for: binding, epoch: Self.epoch(for: date)) - } - - /// Returns aliases accepted around the current clock boundary. - /// - /// The adjacent epochs tolerate ordinary device clock skew while bounding a - /// captured advertisement's useful replay window. - public func acceptedAliases( - for binding: CmxIrohBrokerBindingMetadata, - at date: Date - ) throws -> Set<String> { - let current = try Self.epoch(for: date) - guard current > Int64.min, current < Int64.max else { - throw CmxIrohLANRendezvousAliasError.invalidTimestamp - } - return try Set([ - alias(for: binding, epoch: current - 1), - alias(for: binding, epoch: current), - alias(for: binding, epoch: current + 1), - ]) - } - - /// Resolves an opaque alias only when it identifies one verified binding. - public func binding( - matching alias: String, - among candidates: [CmxIrohBrokerBindingMetadata], - at date: Date - ) throws -> CmxIrohBrokerBindingMetadata? { - guard Self.isCanonicalAlias(alias), - candidates.count <= CmxIrohDiscoveryResponse.maximumBindingCount else { - return nil - } - var match: CmxIrohBrokerBindingMetadata? - for candidate in candidates { - guard try acceptedAliases(for: candidate, at: date).contains(alias) else { - continue - } - guard match == nil else { return nil } - match = candidate - } - return match - } - - func alias( - for binding: CmxIrohBrokerBindingMetadata, - epoch: Int64 - ) throws -> String { - guard binding.platform == .mac else { - throw CmxIrohLANRendezvousAliasError.unsupportedPlatform - } - let transcript = Data( - "cmux/iroh/lan-rendezvous-alias/v1\0\(generation)\0\(epoch)\0\(binding.bindingID)\0\(binding.deviceID)\0\(binding.appInstanceID)\0\(binding.tag)\0\(binding.platform.rawValue)\0\(binding.endpointID.endpointID)\0\(binding.identityGeneration)".utf8 - ) - let key = SymmetricKey(data: keyBytes) - return HMAC<SHA256>.authenticationCode(for: transcript, using: key) - .prefix(16) - .map { String(format: "%02x", $0) } - .joined() - } - - static func epoch(for date: Date) throws -> Int64 { - let value = date.timeIntervalSince1970 - guard value.isFinite, value >= 0, - value <= TimeInterval(Int64.max) * rotationInterval else { - throw CmxIrohLANRendezvousAliasError.invalidTimestamp - } - return Int64((value / rotationInterval).rounded(.down)) - } - - static func isCanonicalAlias(_ value: String) -> Bool { - value.utf8.count == 32 && value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) || (97 ... 102).contains(byte) - } - } - - private static func decodeBase64URL(_ value: String) -> Data? { - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - let standard = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - return Data(base64Encoded: standard) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANSocketAddress.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANSocketAddress.swift deleted file mode 100644 index 7e4aebae..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANSocketAddress.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Darwin -import Foundation - -/// One canonical, numeric Iroh UDP address safe to advertise on a local link. -public struct CmxIrohLANSocketAddress: Equatable, Hashable, Sendable { - public enum Family: Equatable, Hashable, Sendable { - case ipv4 - case ipv6 - } - - /// Canonical `IPv4:port` or `[IPv6]:port` wire representation. - public let value: String - /// Canonical unbracketed IP literal. - public let ipAddress: String - public let port: UInt16 - public let family: Family - - let addressBytes: [UInt8] - - /// Parses a canonical, non-loopback, non-link-local unicast socket address. - public init(_ value: String) throws { - guard value == value.trimmingCharacters(in: .whitespacesAndNewlines), - value.utf8.count <= 80 else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - - let host: String - let portText: String - if value.hasPrefix("[") { - guard let closing = value.firstIndex(of: "]"), - value.index(after: closing) < value.endIndex, - value[value.index(after: closing)] == ":" else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - host = String(value[value.index(after: value.startIndex)..<closing]) - portText = String(value[value.index(closing, offsetBy: 2)...]) - } else { - guard let separator = value.lastIndex(of: ":"), - !value[..<separator].contains(":") else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - host = String(value[..<separator]) - portText = String(value[value.index(after: separator)...]) - } - guard !portText.isEmpty, - portText.utf8.allSatisfy({ (48 ... 57).contains($0) }), - let portValue = UInt16(portText), - portValue != 0, - String(portValue) == portText else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - - if let parsed = Self.parseIPv4(host) { - guard !Self.isForbiddenIPv4(parsed.bytes), value == "\(parsed.canonical):\(portValue)" else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - self.value = value - ipAddress = parsed.canonical - port = portValue - family = .ipv4 - addressBytes = parsed.bytes - return - } - if let parsed = Self.parseIPv6(host) { - guard !Self.isForbiddenIPv6(parsed.bytes), value == "[\(parsed.canonical)]:\(portValue)" else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - self.value = value - ipAddress = parsed.canonical - port = portValue - family = .ipv6 - addressBytes = parsed.bytes - return - } - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - - static func wildcard(_ value: String) -> (family: Family, port: UInt16)? { - if value.hasPrefix("0.0.0.0:"), - let port = UInt16(value.dropFirst("0.0.0.0:".count)), - port != 0, - value == "0.0.0.0:\(port)" { - return (.ipv4, port) - } - if value.hasPrefix("[::]:"), - let port = UInt16(value.dropFirst("[::]:".count)), - port != 0, - value == "[::]:\(port)" { - return (.ipv6, port) - } - return nil - } - - static func canonicalValue(ipAddress: String, port: UInt16) -> String { - ipAddress.contains(":") ? "[\(ipAddress)]:\(port)" : "\(ipAddress):\(port)" - } - - private static func parseIPv4(_ value: String) -> (canonical: String, bytes: [UInt8])? { - var address = in_addr() - guard value.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { return nil } - let bytes = withUnsafeBytes(of: &address) { Array($0) } - var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) - guard inet_ntop(AF_INET, &address, &buffer, socklen_t(buffer.count)) != nil else { return nil } - return (Self.decode(buffer), bytes) - } - - private static func parseIPv6(_ value: String) -> (canonical: String, bytes: [UInt8])? { - guard !value.contains("%") else { return nil } - var address = in6_addr() - guard value.withCString({ inet_pton(AF_INET6, $0, &address) }) == 1 else { return nil } - let bytes = withUnsafeBytes(of: &address) { Array($0) } - var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - guard inet_ntop(AF_INET6, &address, &buffer, socklen_t(buffer.count)) != nil else { return nil } - return (Self.decode(buffer).lowercased(), bytes) - } - - private static func decode(_ buffer: [CChar]) -> String { - String( - decoding: buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, - as: UTF8.self - ) - } - - private static func isForbiddenIPv4(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 4 else { return true } - return bytes[0] == 0 - || bytes[0] == 127 - || bytes[0] >= 224 - || (bytes[0] == 169 && bytes[1] == 254) - || bytes == [255, 255, 255, 255] - } - - private static func isForbiddenIPv6(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 16 else { return true } - if bytes.allSatisfy({ $0 == 0 }) - || bytes == Array(repeating: 0, count: 15) + [1] - || bytes[0] == 0xFF { - return true - } - if bytes[0] == 0xFE, (bytes[1] & 0xC0) == 0x80 { return true } - let mapped = Array(repeating: UInt8(0), count: 10) + [0xFF, 0xFF] - if Array(bytes.prefix(12)) == mapped { - return isForbiddenIPv4(Array(bytes.suffix(4))) - } - return false - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANTXTRecord.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANTXTRecord.swift deleted file mode 100644 index 9ed6d8e3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLANTXTRecord.swift +++ /dev/null @@ -1,90 +0,0 @@ -public import Foundation - -/// Strict, bounded TXT payload for an opaque cmux Iroh Bonjour service. -public struct CmxIrohLANTXTRecord: Equatable, Sendable { - public static let maximumEncodedSize = 512 - public static let maximumAddressCount = 8 - - public let epoch: Int64 - public let addresses: [CmxIrohLANSocketAddress] - - public init(epoch: Int64, addresses: [CmxIrohLANSocketAddress]) throws { - let ordered = addresses.sorted { $0.value < $1.value } - guard epoch >= 0, - !ordered.isEmpty, - ordered.count <= Self.maximumAddressCount, - Set(ordered).count == ordered.count else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - self.epoch = epoch - self.addresses = ordered - guard try encoded().count <= Self.maximumEncodedSize else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - } - - /// Encodes one canonical DNS TXT string sequence. - public func encoded() throws -> Data { - var result = Data() - for string in ["v=1", "e=\(epoch)"] + addresses.map({ "a=\($0.value)" }) { - let bytes = Array(string.utf8) - guard !bytes.isEmpty, bytes.count <= 255 else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - result.append(UInt8(bytes.count)) - result.append(contentsOf: bytes) - } - guard result.count <= Self.maximumEncodedSize else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - return result - } - - /// Decodes only the current canonical field order and rejects extensions. - public init(encoded data: Data) throws { - guard !data.isEmpty, data.count <= Self.maximumEncodedSize else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - var strings: [String] = [] - var offset = data.startIndex - while offset < data.endIndex { - let length = Int(data[offset]) - offset = data.index(after: offset) - guard length > 0, - data.distance(from: offset, to: data.endIndex) >= length else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - let end = data.index(offset, offsetBy: length) - guard let value = String(data: data[offset..<end], encoding: .utf8), - value.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x7E }) else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - strings.append(value) - offset = end - } - guard strings.count >= 3, - strings.count <= Self.maximumAddressCount + 2, - strings[0] == "v=1", - strings[1].hasPrefix("e=") else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - let epochText = strings[1].dropFirst(2) - guard !epochText.isEmpty, - epochText.allSatisfy(\.isNumber), - let epoch = Int64(epochText), - epoch >= 0, - String(epoch) == epochText else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - let addresses = try strings.dropFirst(2).map { value -> CmxIrohLANSocketAddress in - guard value.hasPrefix("a=") else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - return try CmxIrohLANSocketAddress(String(value.dropFirst(2))) - } - try self.init(epoch: epoch, addresses: addresses) - guard try encoded() == data else { - throw CmxIrohLANDiscoveryError.invalidTXTRecord - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLane.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLane.swift deleted file mode 100644 index 4cb890d0..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLane.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// The independent application lane carried by one Iroh QUIC stream. -public enum CmxIrohLane: Equatable, Sendable { - /// The authenticated request, response, and lifecycle control lane. - case control - - /// Ordered server events resumed after the optional last applied sequence. - case serverEvents(cursor: UInt64?) - - /// One terminal's ordered stream resumed after the optional byte cursor. - case terminal(resourceID: CmxIrohResourceID, cursor: UInt64?) - - /// A low-priority artifact stream resumed at an exact byte offset. - case artifact(resourceID: CmxIrohResourceID, offset: UInt64) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibCallbacks.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibCallbacks.swift deleted file mode 100644 index 89fa3b05..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibCallbacks.swift +++ /dev/null @@ -1,13 +0,0 @@ -import IrohLib - -final class CmxIrohLibAddressChangeCallback: AddrChangeCallback, Sendable { - private let handler: @Sendable (EndpointAddr) async -> Void - - init(handler: @escaping @Sendable (EndpointAddr) async -> Void) { - self.handler = handler - } - - func onChange(addr: EndpointAddr) async throws { - await handler(addr) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibConnection.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibConnection.swift deleted file mode 100644 index 0bf6ab75..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibConnection.swift +++ /dev/null @@ -1,127 +0,0 @@ -import CMUXMobileCore -import Foundation -import IrohLib - -struct CmxIrohLibConnection: CmxIrohConnection, CmxIrohConnectionPathInspecting { - let driver: Connection - let peerIdentity: CmxIrohPeerIdentity - - init(driver: Connection) throws { - self.driver = driver - peerIdentity = try CmxIrohLibIdentity.peerIdentity(driver.remoteId()) - } - - func remoteIdentity() async -> CmxIrohPeerIdentity { - peerIdentity - } - - func observedSelectedPath() async -> CmxIrohObservedConnectionPath { - CmxIrohObservedConnectionPath( - snapshots: driver.paths().map(CmxIrohConnectionPathSnapshot.init) - ) - } - - func observedSelectedPathChanges() async -> AsyncStream<CmxIrohObservedConnectionPath> { - AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - let callback = CmxIrohLibPathChangeCallback(continuation: continuation) - let handle = driver.watchPaths(callback: callback) - continuation.yield( - CmxIrohObservedConnectionPath( - snapshots: driver.paths().map(CmxIrohConnectionPathSnapshot.init) - ) - ) - continuation.onTermination = { @Sendable _ in - Task { await handle.stop() } - } - } - } - - func setIncomingStreamLimits( - maximumBidirectionalStreamCount: UInt64, - maximumUnidirectionalStreamCount: UInt64 - ) async throws { - try driver.setMaxConcurrentBiStreams( - count: maximumBidirectionalStreamCount - ) - try driver.setMaxConcurrentUniStreams( - count: maximumUnidirectionalStreamCount - ) - } - - func authorizeNatTraversal() async throws { - try await driver.authorizeNatTraversal() - } - - func openBidirectionalStream() async throws -> CmxIrohBidirectionalStream { - Self.stream(try await driver.openBi()) - } - - func acceptBidirectionalStream() async throws -> CmxIrohBidirectionalStream { - Self.stream(try await driver.acceptBi()) - } - - func openSendStream() async throws -> any CmxIrohSendStream { - CmxIrohLibSendStream(driver: try await driver.openUni()) - } - - func acceptReceiveStream() async throws -> any CmxIrohReceiveStream { - CmxIrohLibReceiveStream(driver: try await driver.acceptUni()) - } - - func waitUntilClosed() async { - _ = await driver.closed() - } - - func isClosed() async -> Bool { - driver.closeReason() != nil - } - - func close(errorCode: UInt64, reason: String) async { - let code = Int64(exactly: errorCode) ?? Int64.max - try? driver.close( - errorCode: code, - reason: Data(reason.utf8.prefix(1_024)) - ) - } - - private static func stream(_ stream: BiStream) -> CmxIrohBidirectionalStream { - CmxIrohBidirectionalStream( - receiveStream: CmxIrohLibReceiveStream(driver: stream.recv()), - sendStream: CmxIrohLibSendStream(driver: stream.send()) - ) - } -} - -enum CmxIrohLibIdentity { - static func peerIdentity(_ value: EndpointId) throws -> CmxIrohPeerIdentity { - let bytes = value.toBytes() - guard bytes.count == 32 else { throw CmxIrohLibError.invalidEndpointIdentity } - return try CmxIrohPeerIdentity(endpointID: bytes.hex) - } - - static func endpointID(_ value: CmxIrohPeerIdentity) throws -> EndpointId { - guard let bytes = Data(canonicalHex: value.endpointID), bytes.count == 32 else { - throw CmxIrohLibError.invalidEndpointIdentity - } - return try EndpointId.fromBytes(bytes: bytes) - } -} - -private extension Data { - init?(canonicalHex value: String) { - guard value.utf8.count.isMultiple(of: 2) else { return nil } - var bytes = Data(capacity: value.utf8.count / 2) - var index = value.startIndex - while index < value.endIndex { - let next = value.index(index, offsetBy: 2) - guard let byte = UInt8(value[index ..< next], radix: 16) else { return nil } - bytes.append(byte) - index = next - } - self = bytes - } - - var hex: String { - map { String(format: "%02x", $0) }.joined() - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift deleted file mode 100644 index 57101006..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpoint.swift +++ /dev/null @@ -1,348 +0,0 @@ -import CMUXMobileCore -import Foundation -import IrohLib - -actor CmxIrohLibEndpoint: CmxIrohEndpoint { - private let driver: Endpoint - private let peerIdentity: CmxIrohPeerIdentity - private let alpns: Set<Data> - private let transportVerificationMode: CmxIrohTransportVerificationMode - private var relayProfile: CmxIrohEndpointRelayProfile - private var relayConfigurations: [String: CmxIrohEndpointRelayProfile.Relay] - private var addressWatch: WatchHandle? - private var onlineTask: Task<Void, Never>? - private var closureTask: Task<Void, Never>? - private var closing = false - private var closed = false - private var reachedOnline = false - private var observedAddressSnapshot: EndpointAddr? - private var terminalHealthEvent: CmxIrohEndpointHealthEvent? - private var observers: [ - UUID: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - ] = [:] - - init( - driver: Endpoint, - identity: CmxIrohPeerIdentity, - configuration: CmxIrohEndpointConfiguration, - transportVerificationMode: CmxIrohTransportVerificationMode = .automatic - ) { - self.driver = driver - peerIdentity = identity - alpns = Set(configuration.alpns) - self.transportVerificationMode = transportVerificationMode - relayProfile = configuration.relayProfile - relayConfigurations = Dictionary( - uniqueKeysWithValues: configuration.relayProfile.activeRelays.map { ($0.url, $0) } - ) - } - - func startMonitoring() { - guard addressWatch == nil, closureTask == nil else { return } - // Iroh's `network_change()` is an input that tells the endpoint to - // rescan, not an observable event. `watchAddr` is the authoritative - // output for route changes after Iroh's native network monitor runs. - addressWatch = driver.watchAddr( - callback: CmxIrohLibAddressChangeCallback { [weak self] address in - await self?.recordAddressSnapshot(address) - } - ) - let driver = driver - onlineTask = Task { [weak self] in - await driver.online() - guard !Task.isCancelled else { return } - await self?.recordHealthEvent(.online) - } - closureTask = Task { [weak self] in - await driver.closed() - guard !Task.isCancelled else { return } - await self?.driverDidClose() - } - } - - func identity() -> CmxIrohPeerIdentity { - peerIdentity - } - - func address() -> CmxIrohEndpointAddress { - let address = observedAddressSnapshot ?? driver.addr() - let now = Date() - let expiresAt = now.addingTimeInterval(CmxIrohPathHint.maximumPrivateHintTTL) - var hints: [CmxIrohPathHint] = [] - if transportVerificationMode != .directOnly, - let relayURL = address.relayUrl(), - relayProfile.allowedRelayURLs.contains(relayURL), - let hint = try? CmxIrohPathHint( - kind: .relayURL, - value: relayURL, - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: expiresAt - ) { - hints.append(hint) - } - if transportVerificationMode != .relayOnly { - hints.append(contentsOf: address.directAddresses().compactMap { value in - try? CmxIrohPathHint( - kind: .directAddress, - value: value, - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: expiresAt - ) - }) - } - return CmxIrohEndpointAddress(identity: peerIdentity, pathHints: hints) - } - - func localDirectAddresses() -> [String] { - transportVerificationMode == .relayOnly - ? [] - : (observedAddressSnapshot ?? driver.addr()).directAddresses() - } - - func connect( - to address: CmxIrohEndpointAddress, - alpn: Data - ) async throws -> any CmxIrohConnection { - guard alpns.contains(alpn) else { throw CmxIrohLibError.unexpectedALPN } - var lastError: (any Error)? - for endpointAddress in try endpointAddresses(address) { - do { - try Task.checkCancellation() - let connection = try await driver.connect(addr: endpointAddress, alpn: alpn) - let wrapped = try CmxIrohLibConnection(driver: connection) - guard await wrapped.remoteIdentity() == address.identity else { - await wrapped.close(errorCode: 1, reason: "identity_mismatch") - throw CmxIrohLibError.remoteIdentityMismatch - } - return wrapped - } catch CmxIrohLibError.remoteIdentityMismatch { - throw CmxIrohLibError.remoteIdentityMismatch - } catch { - try Task.checkCancellation() - lastError = error - } - } - throw lastError ?? CmxIrohLibError.invalidEndpointIdentity - } - - func accept() async throws -> (any CmxIrohConnection)? { - guard let incoming = await driver.acceptNext() else { return nil } - let accepting = try await incoming.accept() - guard alpns.contains(try await accepting.alpn()) else { - throw CmxIrohLibError.unexpectedALPN - } - return try CmxIrohLibConnection(driver: await accepting.connect()) - } - - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) async throws { - let profile = try relayProfile.replacingManagedRelays(relays) - try await replaceRelayProfile(profile) - } - - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) async throws { - if transportVerificationMode == .directOnly { - relayProfile = profile - relayConfigurations = [:] - return - } - let next = Dictionary( - uniqueKeysWithValues: profile.activeRelays.map { ($0.url, $0) } - ) - let now = Date() - for relay in profile.activeRelays { - guard profile.allowedRelayURLs.contains(relay.url) else { - throw CmxIrohLibError.unmanagedRelayURL(relay.url) - } - guard relay.isUsable(at: now) else { - throw CmxIrohLibError.expiredRelayCredential(relay.url) - } - } - - let previous = relayConfigurations - do { - for relay in profile.activeRelays { - try await driver.insertRelay(config: Self.relayConfig(relay)) - } - for staleURL in previous.keys where next[staleURL] == nil { - _ = try await driver.removeRelay(url: staleURL) - } - } catch { - let restored = await restoreRelayConfigurations( - previous: previous, - attempted: next - ) - if !restored { - // A partially mutated driver is no longer safe to publish. Its - // health observer will recreate the same EndpointID from the - // supervisor's unchanged last-known-good configuration. - try? await driver.close() - } - throw error - } - relayProfile = profile - relayConfigurations = next - } - - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { - let observerID = UUID() - return AsyncStream { continuation in - if let terminalHealthEvent { - continuation.yield(terminalHealthEvent) - continuation.finish() - return - } - guard !closed else { - continuation.finish() - return - } - observers[observerID] = continuation - if reachedOnline { - continuation.yield(.online) - } - if observedAddressSnapshot != nil { - continuation.yield(.networkChanged) - } - continuation.onTermination = { [weak self] _ in - Task { await self?.removeObserver(observerID) } - } - } - } - - func isHealthy() -> Bool { - !closing && !closed - } - - func close() async { - guard !closing, !closed else { return } - closing = true - onlineTask?.cancel() - closureTask?.cancel() - onlineTask = nil - closureTask = nil - await addressWatch?.stop() - addressWatch = nil - try? await driver.close() - closed = true - finishObservers() - } - - func endpointAddresses( - _ value: CmxIrohEndpointAddress - ) throws -> [EndpointAddr] { - let now = Date() - let usable = value.pathHints.filter { $0.isUsable(at: now) } - if usable.contains(where: { $0.kind == .relayIdentifier }) { - throw CmxIrohLibError.unsupportedRelayIdentifier - } - var relayURLs: [String] = [] - var observedRelayURLs = Set<String>() - var directAddresses: [String] = [] - var observedDirectAddresses = Set<String>() - for hint in usable { - switch hint.kind { - case .relayURL: - guard transportVerificationMode != .directOnly else { continue } - guard relayProfile.allowedRelayURLs.contains(hint.value) else { - throw CmxIrohLibError.unmanagedRelayURL(hint.value) - } - if observedRelayURLs.insert(hint.value).inserted { - relayURLs.append(hint.value) - } - case .directAddress: - guard transportVerificationMode != .relayOnly else { continue } - if observedDirectAddresses.insert(hint.value).inserted { - directAddresses.append(hint.value) - } - case .relayIdentifier: - break - } - } - let endpointID = try CmxIrohLibIdentity.endpointID(value.identity) - if relayURLs.isEmpty { - return [EndpointAddr(id: endpointID, relayUrl: nil, addresses: directAddresses)] - } - return relayURLs.map { relayURL in - EndpointAddr(id: endpointID, relayUrl: relayURL, addresses: directAddresses) - } - } - - private func driverDidClose() async { - guard !closed else { return } - closed = true - if !closing { - terminalHealthEvent = .closedUnexpectedly - for continuation in observers.values { - continuation.yield(.closedUnexpectedly) - } - } - onlineTask?.cancel() - onlineTask = nil - closureTask = nil - await addressWatch?.stop() - addressWatch = nil - finishObservers() - } - - func recordHealthEvent(_ event: CmxIrohEndpointHealthEvent) { - guard !closing, !closed else { return } - if event == .online { - reachedOnline = true - } - for continuation in observers.values { continuation.yield(event) } - } - - func recordAddressSnapshot(_ address: EndpointAddr) { - guard !closing, !closed, - let identity = try? CmxIrohLibIdentity.peerIdentity(address.id()), - identity == peerIdentity else { - return - } - observedAddressSnapshot = address - for continuation in observers.values { - continuation.yield(.networkChanged) - } - } - - private func removeObserver(_ id: UUID) { - observers.removeValue(forKey: id) - } - - private func finishObservers() { - for continuation in observers.values { continuation.finish() } - observers.removeAll(keepingCapacity: false) - } - - private func restoreRelayConfigurations( - previous: [String: CmxIrohEndpointRelayProfile.Relay], - attempted: [String: CmxIrohEndpointRelayProfile.Relay] - ) async -> Bool { - var restored = true - for relay in previous.values { - do { - try await driver.insertRelay(config: Self.relayConfig(relay)) - } catch { - restored = false - } - } - for addedURL in attempted.keys where previous[addedURL] == nil { - do { - _ = try await driver.removeRelay(url: addedURL) - } catch { - restored = false - } - } - return restored - } - - static func relayConfig(_ relay: CmxIrohEndpointRelayProfile.Relay) -> RelayConfig { - RelayConfig( - url: relay.url, - quicPort: nil, - authToken: relay.authenticationToken - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift deleted file mode 100644 index 4c1f6aff..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibEndpointFactory.swift +++ /dev/null @@ -1,87 +0,0 @@ -public import CMUXMobileCore -import Foundation -import IrohLib - -/// Production endpoint factory using the forked Iroh Swift bindings. -public struct CmxIrohLibEndpointFactory: CmxIrohEndpointFactory { - private let transportVerificationMode: CmxIrohTransportVerificationMode - - /// Creates an endpoint factory with an optional debug transport constraint. - /// - /// - Parameter transportVerificationMode: The path class the endpoint may use. - public init( - transportVerificationMode: CmxIrohTransportVerificationMode = .automatic - ) { - self.transportVerificationMode = transportVerificationMode - } - - public func bind( - configuration: CmxIrohEndpointConfiguration - ) async throws -> any CmxIrohEndpoint { - let driver: Endpoint - do { - driver = try await bindDriver( - configuration: configuration, - socketAddress: configuration.bindPolicy.socketAddress - ) - } catch where configuration.bindPolicy.allowsEphemeralFallback { - driver = try await bindDriver( - configuration: configuration, - socketAddress: nil - ) - } - let identity = try CmxIrohLibIdentity.peerIdentity(driver.id()) - let endpoint = CmxIrohLibEndpoint( - driver: driver, - identity: identity, - configuration: configuration, - transportVerificationMode: transportVerificationMode - ) - await endpoint.startMonitoring() - return endpoint - } - - private func bindDriver( - configuration: CmxIrohEndpointConfiguration, - socketAddress: String? - ) async throws -> Endpoint { - let relayMap = RelayMap.empty() - if transportVerificationMode != .directOnly { - let now = Date() - for relay in configuration.relayProfile.activeRelays { - guard relay.isUsable(at: now) else { - throw CmxIrohLibError.expiredRelayCredential(relay.url) - } - try relayMap.insert(config: CmxIrohLibEndpoint.relayConfig(relay)) - } - } - let options = Self.endpointOptions( - configuration: configuration, - socketAddress: socketAddress, - relayMap: relayMap, - transportVerificationMode: transportVerificationMode - ) - return try await Endpoint.bind(options: options) - } - - static func endpointOptions( - configuration: CmxIrohEndpointConfiguration, - socketAddress: String?, - relayMap: RelayMap, - transportVerificationMode: CmxIrohTransportVerificationMode = .automatic - ) -> EndpointOptions { - EndpointOptions( - preset: presetMinimal(), - bindAddr: socketAddress, - secretKey: configuration.secretKey.bytes, - alpns: configuration.alpns, - relayMode: transportVerificationMode == .directOnly - ? RelayMode.disabled() - : RelayMode.custom(map: relayMap), - portMappingEnabled: false, - deferNatTraversalUntilAuthorized: true, - initialMaxConcurrentBiStreams: 0, - initialMaxConcurrentUniStreams: 0 - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift deleted file mode 100644 index 694ca4f4..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibError.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// Validation failures at the generated Iroh Swift binding boundary. -public enum CmxIrohLibError: Error, Equatable, Sendable { - case invalidEndpointIdentity - case remoteIdentityMismatch - case unmanagedRelayURL(String) - case expiredRelayCredential(String) - case unsupportedRelayIdentifier - case unexpectedALPN - case invalidReceiveLimit(Int) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibPathChangeCallback.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibPathChangeCallback.swift deleted file mode 100644 index 80009aef..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibPathChangeCallback.swift +++ /dev/null @@ -1,18 +0,0 @@ -import IrohLib - -/// Bridges Iroh's live path watcher into a coordinate-private async stream. -final class CmxIrohLibPathChangeCallback: PathChangeCallback, Sendable { - private let continuation: AsyncStream<CmxIrohObservedConnectionPath>.Continuation - - init(continuation: AsyncStream<CmxIrohObservedConnectionPath>.Continuation) { - self.continuation = continuation - } - - func onChange(paths: [PathSnapshot]) async { - continuation.yield( - CmxIrohObservedConnectionPath( - snapshots: paths.map(CmxIrohConnectionPathSnapshot.init) - ) - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibStreams.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibStreams.swift deleted file mode 100644 index 230ea9c9..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLibStreams.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import IrohLib - -struct CmxIrohLibSendStream: CmxIrohSendStream { - let driver: SendStream - - func send(_ data: Data) async throws { - try await driver.writeAll(buf: data) - } - - func finish() async throws { - try await driver.finish() - } - - func reset(errorCode: UInt64) async { - try? await driver.reset(errorCode: errorCode) - } - - func setPriority(_ priority: Int32) async throws { - try await driver.setPriority(p: priority) - } -} - -struct CmxIrohLibReceiveStream: CmxIrohReceiveStream { - let driver: RecvStream - - func receive(maximumByteCount: Int) async throws -> Data? { - guard let limit = UInt32(exactly: maximumByteCount), limit > 0 else { - throw CmxIrohLibError.invalidReceiveLimit(maximumByteCount) - } - let data = try await driver.read(sizeLimit: limit) - return data.isEmpty ? nil : data - } - - func stop(errorCode: UInt64) async { - try? await driver.stop(errorCode: errorCode) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLiveDiscoveryRefreshOutcome.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLiveDiscoveryRefreshOutcome.swift deleted file mode 100644 index 14d865cd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLiveDiscoveryRefreshOutcome.swift +++ /dev/null @@ -1,14 +0,0 @@ -public import CMUXMobileCore - -/// The privacy-safe result of requesting a fresh broker discovery snapshot. -/// -/// Failures carry only the bounded diagnostic category. The outcome never -/// retains an error description, endpoint identity, relay URL, account value, -/// or network address. -public enum CmxIrohLiveDiscoveryRefreshOutcome: Equatable, Sendable { - /// A new verified broker snapshot was installed for first-pair discovery. - case refreshed - - /// No new live snapshot was installed for the given categorical reason. - case failed(DiagnosticFailureKind) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLocalBindingExpectation.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLocalBindingExpectation.swift deleted file mode 100644 index c734f588..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLocalBindingExpectation.swift +++ /dev/null @@ -1,70 +0,0 @@ -public import CMUXMobileCore -import Foundation - -/// The exact local endpoint tuple an authenticated discovery response must contain. -public struct CmxIrohLocalBindingExpectation: Equatable, Sendable { - public let deviceID: String - public let appInstanceID: String - public let tag: String - public let platform: CmxIrohPlatform - public let endpointID: CmxIrohPeerIdentity - public let identityGeneration: Int - public let pairingEnabled: Bool - public let capabilities: [String] - - public init( - deviceID: String, - appInstanceID: String, - tag: String, - platform: CmxIrohPlatform, - endpointID: CmxIrohPeerIdentity, - identityGeneration: Int, - pairingEnabled: Bool, - capabilities: [String] - ) throws { - guard Self.isCanonicalUUID(deviceID), - Self.isCanonicalUUID(appInstanceID), - Self.isSafeToken(tag), - (1 ... Int(Int32.max)).contains(identityGeneration), - capabilities.count <= 32, - Set(capabilities).count == capabilities.count, - capabilities.allSatisfy(Self.isSafeToken) else { - throw CmxIrohLocalBindingExpectationError.invalidExpectation - } - self.deviceID = deviceID - self.appInstanceID = appInstanceID - self.tag = tag - self.platform = platform - self.endpointID = endpointID - self.identityGeneration = identityGeneration - self.pairingEnabled = pairingEnabled - self.capabilities = capabilities - } - - /// Returns whether `binding` is the single broker row this process registered. - public func matches(_ binding: CmxIrohBrokerBinding) -> Bool { - binding.deviceID == deviceID - && binding.appInstanceID == appInstanceID - && binding.tag == tag - && binding.platform == platform - && binding.endpointID == endpointID - && binding.identityGeneration == identityGeneration - && binding.pairingEnabled == pairingEnabled - && binding.capabilities.count == capabilities.count - && Set(binding.capabilities) == Set(capabilities) - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func isSafeToken(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLocalBindingExpectationError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLocalBindingExpectationError.swift deleted file mode 100644 index ec2a5560..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohLocalBindingExpectationError.swift +++ /dev/null @@ -1,4 +0,0 @@ -/// Validation failures for a local broker-binding expectation. -public enum CmxIrohLocalBindingExpectationError: Error, Equatable, Sendable { - case invalidExpectation -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift deleted file mode 100644 index d45520df..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayCredential.swift +++ /dev/null @@ -1,55 +0,0 @@ -/// One broker-issued credential associated with one exact managed relay URL. -public struct CmxIrohManagedRelayCredential: Codable, Equatable, Sendable, - CustomStringConvertible, CustomDebugStringConvertible -{ - /// The exact canonical relay URL covered by this credential. - public let relayURL: String - - /// The opaque relay authentication token. - public let token: String - - /// The provider-enforced expiry in ISO 8601 format. - public let expiresAt: String - - /// The replacement time in ISO 8601 format. - public let refreshAfter: String - - /// Creates one URL-bound managed relay credential. - /// - /// Structural and lifetime validation is centralized in - /// ``CmxIrohRelayTokenResponse/relayConfigurations(now:)`` so network and - /// restored credentials follow the same validation path. - /// - /// - Parameters: - /// - relayURL: The exact managed relay URL covered by the token. - /// - token: The provider-issued opaque relay token. - /// - expiresAt: The provider-enforced expiry in ISO 8601 format. - /// - refreshAfter: The replacement time in ISO 8601 format. - public init( - relayURL: String, - token: String, - expiresAt: String, - refreshAfter: String - ) { - self.relayURL = relayURL - self.token = token - self.expiresAt = expiresAt - self.refreshAfter = refreshAfter - } - - /// A log-safe representation that never includes the opaque token. - public var description: String { - "CmxIrohManagedRelayCredential(relayURL: \(relayURL), token: <redacted>, " - + "expiresAt: \(expiresAt), refreshAfter: \(refreshAfter))" - } - - /// A debug representation that never includes the opaque token. - public var debugDescription: String { description } - - private enum CodingKeys: String, CodingKey { - case relayURL = "relay_url" - case token - case expiresAt = "expires_at" - case refreshAfter = "refresh_after" - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayDescriptor.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayDescriptor.swift deleted file mode 100644 index ba49d7bb..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayDescriptor.swift +++ /dev/null @@ -1,21 +0,0 @@ -/// One broker-managed relay advertised by a verified signed policy. -public struct CmxIrohManagedRelayDescriptor: Equatable, Sendable { - /// Stable identifier used by local user selection. - public let id: String - - /// Stable provider identifier such as `cmux` or `n0`. - public let provider: String - - /// Provider-defined region identifier used for diagnostics and selection UI. - public let region: String - - /// Canonical HTTPS relay origin. - public let url: String - - init(id: String, provider: String, region: String, url: String) { - self.id = id - self.provider = provider - self.region = region - self.url = url - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayPolicy.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayPolicy.swift deleted file mode 100644 index c8421746..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelayPolicy.swift +++ /dev/null @@ -1,51 +0,0 @@ -/// A verified, bounded catalog of managed Iroh relays. -public struct CmxIrohManagedRelayPolicy: Equatable, Sendable { - /// Policy schema version. - public let version: Int - - /// Canonical UUID identifying this signed policy publication. - public let policyID: String - - /// Monotonic server sequence used for rollback protection. - public let sequence: Int64 - - /// Unix time when the policy was issued. - public let issuedAt: Int64 - - /// Unix time before which the policy must not be used. - public let notBefore: Int64 - - /// Unix time after which the policy must not be used. - public let expiresAt: Int64 - - /// Application audience restricting where the policy is accepted. - public let audience: String - - /// Relay wire protocol implemented by every descriptor in this policy. - public let relayProtocol: String - - /// Ordered managed relay catalog from which local selection is resolved. - public let relays: [CmxIrohManagedRelayDescriptor] - - init( - version: Int, - policyID: String, - sequence: Int64, - issuedAt: Int64, - notBefore: Int64, - expiresAt: Int64, - audience: String, - relayProtocol: String, - relays: [CmxIrohManagedRelayDescriptor] - ) { - self.version = version - self.policyID = policyID - self.sequence = sequence - self.issuedAt = issuedAt - self.notBefore = notBefore - self.expiresAt = expiresAt - self.audience = audience - self.relayProtocol = relayProtocol - self.relays = relays - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelaySelection.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelaySelection.swift deleted file mode 100644 index 6d434c8f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohManagedRelaySelection.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// A user's selection within the currently signed managed-relay catalog. -public enum CmxIrohManagedRelaySelection: Equatable, Sendable { - /// Allow every compatible relay and let Iroh choose the closest home relay. - case automatic - - /// Allow only the selected stable relay identifiers. - case only(Set<String>) - - /// Resolves this selection against one verified policy. - /// - /// - Parameter policy: The verified managed-relay catalog. - /// - Returns: Relays in signed policy order, filtered by the local selection. - /// - Throws: ``CmxIrohRelayPolicyError/invalidSelection`` for stale or empty IDs. - public func resolve( - in policy: CmxIrohManagedRelayPolicy - ) throws -> [CmxIrohManagedRelayDescriptor] { - switch self { - case .automatic: - return policy.relays - case let .only(ids): - guard !ids.isEmpty, - ids.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohRelayPolicyError.invalidSelection - } - let resolved = policy.relays.filter { ids.contains($0.id) } - guard resolved.count == ids.count else { - throw CmxIrohRelayPolicyError.invalidSelection - } - return resolved - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohNetworkPathSnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohNetworkPathSnapshot.swift deleted file mode 100644 index b16a3e99..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohNetworkPathSnapshot.swift +++ /dev/null @@ -1,19 +0,0 @@ -public import CMUXMobileCore - -/// One generation of locally observed private-network reachability. -public struct CmxIrohNetworkPathSnapshot: Equatable, Sendable { - /// A process-monotonic generation advanced for every path change. - public let generation: UInt64 - - /// Provider-qualified profiles active in this exact generation. - public let activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> - - /// Creates a path snapshot supplied by the platform network observer. - public init( - generation: UInt64, - activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> - ) { - self.generation = generation - self.activeNetworkProfiles = activeNetworkProfiles - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohNetworkPathSnapshotComposer.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohNetworkPathSnapshotComposer.swift deleted file mode 100644 index 87e464f2..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohNetworkPathSnapshotComposer.swift +++ /dev/null @@ -1,39 +0,0 @@ -public import CMUXMobileCore - -/// Assigns one process-monotonic generation to a platform path snapshot joined -/// with device-local custom private-path preferences. -public actor CmxIrohNetworkPathSnapshotComposer { - private struct Input: Equatable { - let platformGeneration: UInt64 - let platformProfiles: Set<CmxIrohNetworkProfileKey> - let customGeneration: UInt64 - let customProfiles: Set<CmxIrohNetworkProfileKey> - } - - private var generation: UInt64 = 1 - private var previousInput: Input? - - public init() {} - - public func compose( - platform: CmxIrohNetworkPathSnapshot, - custom: CmxIrohCustomPrivatePathSnapshot - ) -> CmxIrohNetworkPathSnapshot { - let input = Input( - platformGeneration: platform.generation, - platformProfiles: platform.activeNetworkProfiles, - customGeneration: custom.generation, - customProfiles: custom.activeNetworkProfiles - ) - if let previousInput, previousInput != input { - generation = generation == .max ? 1 : generation + 1 - } - previousInput = input - return CmxIrohNetworkPathSnapshot( - generation: generation, - activeNetworkProfiles: platform.activeNetworkProfiles.union( - custom.activeNetworkProfiles - ) - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohObservedConnectionPath.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohObservedConnectionPath.swift deleted file mode 100644 index eb797852..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohObservedConnectionPath.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Raw selected-path evidence retained only inside the transport package. -enum CmxIrohObservedConnectionPath: Equatable, Sendable { - case unavailable - case direct - case privateNetwork - case relay(url: String) - - init(snapshots: [CmxIrohConnectionPathSnapshot]) { - guard let selected = snapshots.first(where: \.isSelected) else { - self = .unavailable - return - } - if selected.isRelay { - self = .relay(url: selected.remoteAddress) - } else if selected.isIP { - self = CmxIrohIPAddressScope(socketAddress: selected.remoteAddress).isPrivate - ? .privateNetwork - : .direct - } else { - self = .unavailable - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingInvitation.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingInvitation.swift deleted file mode 100644 index b2659f94..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingInvitation.swift +++ /dev/null @@ -1,77 +0,0 @@ -import Foundation - -/// Five-minute QR authorization created by the Mac for one offline pairing attempt. -public struct CmxIrohOfflinePairingInvitation: Codable, Equatable, Sendable { - public let version: Int - public let sessionID: String - public let proof: String - public let expiresAt: Int64 - public let acceptorAttestation: String - - private enum CodingKeys: String, CodingKey { - case version - case sessionID = "session_id" - case proof - case expiresAt = "expires_at" - case acceptorAttestation = "acceptor_attestation" - } - - init( - sessionID: String, - proof: String, - expiresAt: Int64, - acceptorAttestation: String - ) { - version = 1 - self.sessionID = sessionID - self.proof = proof - self.expiresAt = expiresAt - self.acceptorAttestation = acceptorAttestation - } - - /// Creates the control-stream credential presented by the iOS initiator. - public func admissionCredential( - initiatorAttestation: String - ) throws -> CmxIrohAdmissionCredential { - guard version == 1, - let proofBytes = Self.decodeBase64URL(proof), - proofBytes.count == 32 else { - throw CmxIrohOfflinePairingSessionError.invalidInvitation - } - return try CmxIrohAdmissionCredential.offlinePairing( - endpointAttestation: initiatorAttestation, - invitationID: CmxIrohResourceID(sessionID), - proof: proofBytes - ) - } - - private static func decodeBase64URL(_ value: String) -> Data? { - guard !value.isEmpty, - value.utf8.allSatisfy({ byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || byte == 45 || byte == 95 - }) else { - return nil - } - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - let standard = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - guard let decoded = Data(base64Encoded: standard), - decoded.base64URL == value else { - return nil - } - return decoded - } -} - -extension Data { - fileprivate var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingSessionError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingSessionError.swift deleted file mode 100644 index 85771376..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingSessionError.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// Local invitation lifecycle failures. Grant-verification failures remain distinct. -public enum CmxIrohOfflinePairingSessionError: Error, Equatable, Sendable { - case pairingDisabled - case revoked - case invalidInvitation - case sessionUnavailable - case invalidProof - case randomnessUnavailable -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingSessions.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingSessions.swift deleted file mode 100644 index 927ffa8e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOfflinePairingSessions.swift +++ /dev/null @@ -1,219 +0,0 @@ -import CryptoKit -public import CMUXMobileCore -public import Foundation - -/// Actor-isolated one-use Mac invitation state for offline same-account pairing. -public actor CmxIrohOfflinePairingSessions { - private struct Session: Sendable { - let id: String - let acceptor: CmxIrohEndpointExpectation - let acceptorAttestation: String - let keys: CmxIrohGrantVerificationKeySet - let proofHash: Data - let createdAt: Int64 - let expiresAt: Int64 - var consumedAt: Int64? - } - - private let verifier: CmxIrohGrantVerifier - private let randomness: any CmxIrohRandomByteGenerating - private let makeUUID: @Sendable () -> UUID - private var pairingEnabled: Bool - private var revokedBindingIDs: Set<String> = [] - private var session: Session? - - public init( - pairingEnabled: Bool, - verifier: CmxIrohGrantVerifier = CmxIrohGrantVerifier(), - randomness: any CmxIrohRandomByteGenerating = CmxIrohSystemRandomByteGenerator(), - makeUUID: @escaping @Sendable () -> UUID = { UUID() } - ) { - self.pairingEnabled = pairingEnabled - self.verifier = verifier - self.randomness = randomness - self.makeUUID = makeUUID - } - - /// Enables or disables new and pending offline pairing admission. - public func setPairingEnabled(_ enabled: Bool) { - pairingEnabled = enabled - if !enabled { session = nil } - } - - /// Applies a local revoke immediately, before any backend refresh can arrive. - public func revoke(bindingID: String) { - revokedBindingIDs.insert(bindingID) - if session?.acceptor.bindingID == bindingID { session = nil } - } - - /// Invalidates the current QR without changing pairing policy. - public func invalidate() { - session = nil - } - - /// Creates one five-minute invitation after validating the Mac's cached attestation. - public func createInvitation( - acceptorAttestation: String, - keys: CmxIrohGrantVerificationKeySet, - acceptor: CmxIrohEndpointExpectation, - now: Date - ) throws -> CmxIrohOfflinePairingInvitation { - guard pairingEnabled else { - throw CmxIrohOfflinePairingSessionError.pairingDisabled - } - guard !revokedBindingIDs.contains(acceptor.bindingID) else { - throw CmxIrohOfflinePairingSessionError.revoked - } - guard acceptor.platform == .mac else { - throw CmxIrohGrantVerifierError.identityMismatch - } - _ = try verifier.verifyEndpointAttestation( - acceptorAttestation, - keys: keys, - expected: acceptor, - now: now - ) - let createdAt = try Self.seconds(now) - let expiration = createdAt.addingReportingOverflow(5 * 60) - guard !expiration.overflow else { - throw CmxIrohOfflinePairingSessionError.invalidInvitation - } - let proof = try randomness.randomBytes(count: 32) - guard proof.count == 32 else { - throw CmxIrohOfflinePairingSessionError.randomnessUnavailable - } - let sessionID = makeUUID().uuidString.lowercased() - guard Self.isCanonicalUUID(sessionID) else { - throw CmxIrohOfflinePairingSessionError.randomnessUnavailable - } - session = Session( - id: sessionID, - acceptor: acceptor, - acceptorAttestation: acceptorAttestation, - keys: keys, - proofHash: Self.proofHash(sessionID: sessionID, acceptor: acceptor, proof: proof), - createdAt: createdAt, - expiresAt: expiration.partialValue, - consumedAt: nil - ) - return CmxIrohOfflinePairingInvitation( - sessionID: sessionID, - proof: proof.base64URL, - expiresAt: expiration.partialValue, - acceptorAttestation: acceptorAttestation - ) - } - - /// Atomically verifies and consumes one invitation against the live QUIC peer. - public func verifyAndConsume( - credential: CmxIrohAdmissionCredential, - authenticatedPeerID: CmxIrohPeerIdentity, - now: Date - ) throws -> CmxIrohVerifiedOfflinePair { - guard pairingEnabled else { - throw CmxIrohOfflinePairingSessionError.pairingDisabled - } - guard credential.kind == .offlinePairing, - let initiatorAttestation = credential.endpointAttestation, - let invitationID = credential.invitationID?.value, - let proof = credential.offlineProof, - proof.count == 32 else { - throw CmxIrohOfflinePairingSessionError.invalidInvitation - } - guard var current = session, - current.consumedAt == nil, - current.id == invitationID else { - throw CmxIrohOfflinePairingSessionError.sessionUnavailable - } - let nowSeconds = try Self.seconds(now) - let futureTolerance = nowSeconds.addingReportingOverflow(30) - let lifetime = current.expiresAt.subtractingReportingOverflow(current.createdAt) - guard !futureTolerance.overflow, - !lifetime.overflow, - current.createdAt <= futureTolerance.partialValue, - lifetime.partialValue > 0, - lifetime.partialValue <= 5 * 60, - current.expiresAt > nowSeconds else { - throw CmxIrohOfflinePairingSessionError.sessionUnavailable - } - let actualHash = Self.proofHash( - sessionID: current.id, - acceptor: current.acceptor, - proof: proof - ) - guard Self.constantTimeEqual(current.proofHash, actualHash) else { - throw CmxIrohOfflinePairingSessionError.invalidProof - } - let initiatorClaims = try verifier.verifyEndpointAttestation( - initiatorAttestation, - keys: current.keys, - authenticatedEndpointID: authenticatedPeerID, - requiredPlatform: .ios, - now: now - ) - let initiator = CmxIrohEndpointExpectation( - bindingID: initiatorClaims.bindingID, - deviceID: initiatorClaims.deviceID, - endpointID: initiatorClaims.endpointID, - identityGeneration: initiatorClaims.identityGeneration, - platform: initiatorClaims.platform - ) - guard !revokedBindingIDs.contains(initiator.bindingID), - !revokedBindingIDs.contains(current.acceptor.bindingID) else { - throw CmxIrohOfflinePairingSessionError.revoked - } - let verified = try verifier.verifyOfflineSameAccountPair( - initiatorToken: initiatorAttestation, - acceptorToken: current.acceptorAttestation, - keys: current.keys, - initiator: initiator, - acceptor: current.acceptor, - now: now - ) - current.consumedAt = nowSeconds - session = current - return verified - } - - private static func proofHash( - sessionID: String, - acceptor: CmxIrohEndpointExpectation, - proof: Data - ) -> Data { - var transcript = Data( - "cmux/iroh/offline-pair-session/v1\n\(sessionID)\n\(acceptor.bindingID)\n\(acceptor.deviceID)\n\(acceptor.endpointID.endpointID)\n\(acceptor.identityGeneration)\n\(acceptor.platform.rawValue)\n".utf8 - ) - transcript.append(proof) - return Data(SHA256.hash(data: transcript)) - } - - private static func seconds(_ date: Date) throws -> Int64 { - let value = date.timeIntervalSince1970 - guard value.isFinite, - value >= TimeInterval(Int64.min), - value <= TimeInterval(Int64.max) else { - throw CmxIrohOfflinePairingSessionError.invalidInvitation - } - return Int64(value.rounded(.down)) - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } - - private static func constantTimeEqual(_ left: Data, _ right: Data) -> Bool { - guard left.count == right.count else { return false } - var difference: UInt8 = 0 - for (lhs, rhs) in zip(left, right) { difference |= lhs ^ rhs } - return difference == 0 - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift deleted file mode 100644 index 571de262..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionAuthorization.swift +++ /dev/null @@ -1,103 +0,0 @@ -public import Foundation - -/// Online revocation state for locally authenticated pair authority. -public enum CmxIrohOnlineAdmissionAuthorization: Equatable, Sendable { - /// The exact peer may use the transport until the lease is invalidated. - case accepted(CmxIrohOnlineAdmissionLease) - - /// Local authentication or current broker policy denied the peer. - case denied -} - -/// Locally authenticated authority whose broker bindings remain subject to refresh. -public struct CmxIrohOnlineAdmissionLease: Equatable, Sendable { - enum Authority: Equatable, Sendable { - case pairGrant( - grantID: String, - initiator: CmxIrohGrantPeer, - acceptor: CmxIrohGrantPeer - ) - case offlinePairing( - initiator: CmxIrohEndpointExpectation, - acceptor: CmxIrohEndpointExpectation - ) - - var initiatorBindingID: String { - switch self { - case let .pairGrant(_, initiator, _): initiator.bindingID - case let .offlinePairing(initiator, _): initiator.bindingID - } - } - - var acceptorBindingID: String { - switch self { - case let .pairGrant(_, _, acceptor): acceptor.bindingID - case let .offlinePairing(_, acceptor): acceptor.bindingID - } - } - } - - public let peer: CmxIrohAdmittedPeer - public let expiresAt: Date - - let authority: Authority - let onlineValidatedAt: Date? - - private init( - peer: CmxIrohAdmittedPeer, - expiresAt: Date, - authority: Authority, - onlineValidatedAt: Date? - ) { - self.peer = peer - self.expiresAt = expiresAt - self.authority = authority - self.onlineValidatedAt = onlineValidatedAt - } - - init(claims: CmxIrohPairGrantClaims, onlineValidatedAt: Date?) { - peer = CmxIrohAdmittedPeer(peer: claims.initiator) - expiresAt = Date(timeIntervalSince1970: TimeInterval(claims.expiresAt)) - authority = .pairGrant( - grantID: claims.grantID, - initiator: claims.initiator, - acceptor: claims.acceptor - ) - self.onlineValidatedAt = onlineValidatedAt - } - - init(pair: CmxIrohVerifiedOfflinePair, onlineValidatedAt: Date?) { - peer = CmxIrohAdmittedPeer(attestation: pair.initiator) - expiresAt = Date( - timeIntervalSince1970: TimeInterval( - min(pair.initiator.expiresAt, pair.acceptor.expiresAt) - ) - ) - authority = .offlinePairing( - initiator: CmxIrohEndpointExpectation( - bindingID: pair.initiator.bindingID, - deviceID: pair.initiator.deviceID, - endpointID: pair.initiator.endpointID, - identityGeneration: pair.initiator.identityGeneration, - platform: pair.initiator.platform - ), - acceptor: CmxIrohEndpointExpectation( - bindingID: pair.acceptor.bindingID, - deviceID: pair.acceptor.deviceID, - endpointID: pair.acceptor.endpointID, - identityGeneration: pair.acceptor.identityGeneration, - platform: pair.acceptor.platform - ) - ) - self.onlineValidatedAt = onlineValidatedAt - } - - func validatedOnline(at date: Date) -> Self { - Self( - peer: peer, - expiresAt: expiresAt, - authority: authority, - onlineValidatedAt: date - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift deleted file mode 100644 index 8332ab65..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift +++ /dev/null @@ -1,501 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Locally authenticates pair grants, then enforces current broker revocation policy. -public actor CmxIrohOnlineAdmissionRegistry { - public typealias InvalidationHandler = @Sendable () async -> Void - - private struct Snapshot: Sendable { - let id: UUID - let response: CmxIrohDiscoveryResponse - let fetchedAt: Date - } - - private struct SnapshotRead: Sendable { - let snapshot: Snapshot - let reusedCachedSnapshot: Bool - } - - private struct Refresh: Sendable { - let id: UUID - let task: Task<CmxIrohDiscoveryResponse, any Error> - } - - private struct Monitor { - let lease: CmxIrohOnlineAdmissionLease - let onInvalidated: InvalidationHandler - let task: Task<Void, Never>? - let connectionLifetimeTask: Task<Void, Never>? - } - - private enum Revalidation { - case active(Date) - case connectivity - case terminal - } - - /// A successful broker snapshot is reused for no more than 30 seconds. - public static let maximumOnlineSnapshotAge: TimeInterval = 30 - - private let broker: any CmxIrohDiscoveryServing - private var managedRelayURLs: Set<String> - private let routeContractVersion: Int - private let verifier: CmxIrohGrantVerifier - private let clock: any CmxIrohRelayClock - private var keys: CmxIrohGrantVerificationKeySet - private var acceptor: CmxIrohGrantPeer - private var snapshot: Snapshot? - private var refresh: Refresh? - private var policyRevision: UInt64 = 0 - private var deniedBindingIDs: Set<String> = [] - private var monitors: [UUID: Monitor] = [:] - - public init( - broker: any CmxIrohDiscoveryServing, - keys: CmxIrohGrantVerificationKeySet, - acceptor: CmxIrohGrantPeer, - managedRelayURLs: Set<String>, - routeContractVersion: Int = 1, - verifier: CmxIrohGrantVerifier = CmxIrohGrantVerifier(), - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock() - ) { - self.broker = broker - self.keys = keys - self.acceptor = acceptor - self.managedRelayURLs = managedRelayURLs - self.routeContractVersion = routeContractVersion - self.verifier = verifier - self.clock = clock - } - - /// Replaces locally trusted grant keys and the exact current Mac binding. - public func update( - keys: CmxIrohGrantVerificationKeySet, - acceptor: CmxIrohGrantPeer - ) { - self.keys = keys - self.acceptor = acceptor - policyRevision &+= 1 - snapshot = nil - refresh?.task.cancel() - refresh = nil - } - - /// Replaces the verified managed fleet used for online revalidation. - func updateManagedRelayURLs(_ relayURLs: Set<String>) { - managedRelayURLs = relayURLs - policyRevision &+= 1 - snapshot = nil - refresh?.task.cancel() - refresh = nil - } - - /// Verifies signature and TLS identity before consulting the authenticated broker. - public func authorizePairGrant( - _ token: String, - authenticatedPeerID: CmxIrohPeerIdentity - ) async -> CmxIrohOnlineAdmissionAuthorization { - let claims: CmxIrohPairGrantClaims - do { - claims = try verifier.verifyPairGrant( - token, - keys: keys, - authenticatedInitiatorID: authenticatedPeerID, - acceptor: acceptor, - now: clock.now() - ) - } catch { - return .denied - } - return await authorize( - CmxIrohOnlineAdmissionLease(claims: claims, onlineValidatedAt: nil) - ) - } - - /// AdmissionController is the only production caller, after locally verifying and - /// consuming the one-use proof, TLS identity, and both signed attestations. - func authorizeOfflinePair( - _ pair: CmxIrohVerifiedOfflinePair - ) async -> CmxIrohOnlineAdmissionAuthorization { - let lease = CmxIrohOnlineAdmissionLease(pair: pair, onlineValidatedAt: nil) - let verifiedAcceptor = CmxIrohEndpointExpectation( - bindingID: pair.acceptor.bindingID, - deviceID: pair.acceptor.deviceID, - endpointID: pair.acceptor.endpointID, - identityGeneration: pair.acceptor.identityGeneration, - platform: pair.acceptor.platform - ) - guard pair.initiator.platform == .ios, - pair.acceptor.platform == .mac, - currentAcceptorExpectation() == verifiedAcceptor else { - return .denied - } - return await authorize(lease) - } - - private func authorize( - _ lease: CmxIrohOnlineAdmissionLease - ) async -> CmxIrohOnlineAdmissionAuthorization { - guard !isDenied(lease), !isExpired(lease) else { return .denied } - let revision = policyRevision - do { - guard let online = try await validatedSnapshot( - for: lease, - policyRevision: revision - ) else { - await invalidateDeniedMonitors() - return .denied - } - guard !isExpired(lease) else { - return .denied - } - return .accepted(lease.validatedOnline(at: online.fetchedAt)) - } catch { - guard Self.isConnectivity(error), - policyRevision == revision, - !isDenied(lease), - !isExpired(lease) else { - return .denied - } - return .accepted(lease) - } - } - - /// Starts an idle-safe lease monitor owned by the exact live connection. - /// - /// Normal connection close removes the monitor. Revocation, terminal broker - /// failure, or lease expiry invokes only the supplied session callback. - public func monitor( - _ lease: CmxIrohOnlineAdmissionLease, - connection: any CmxIrohConnection, - onInvalidated: @escaping InvalidationHandler - ) { - let id = UUID() - monitors[id] = Monitor( - lease: lease, - onInvalidated: onInvalidated, - task: nil, - connectionLifetimeTask: nil - ) - let task = Task { [weak self] in - guard let self else { return } - await self.monitorLoop(id: id, lease: lease, onInvalidated: onInvalidated) - } - let connectionLifetimeTask = Task { [weak self] in - await connection.waitUntilClosed() - await self?.connectionClosed(id: id) - } - if let monitor = monitors[id] { - monitors[id] = Monitor( - lease: monitor.lease, - onInvalidated: monitor.onInvalidated, - task: task, - connectionLifetimeTask: connectionLifetimeTask - ) - } else { - task.cancel() - connectionLifetimeTask.cancel() - } - } - - /// Applies local revoke state immediately to new and already-admitted sessions. - public func revoke(bindingID: String) async { - deniedBindingIDs.insert(bindingID) - policyRevision &+= 1 - await invalidateDeniedMonitors() - } - - /// Cancels all lease timers without changing endpoint ownership. - public func stop() { - let active = monitors.values - monitors.removeAll() - for monitor in active { - monitor.task?.cancel() - monitor.connectionLifetimeTask?.cancel() - } - refresh?.task.cancel() - refresh = nil - } - - private func monitorLoop( - id: UUID, - lease: CmxIrohOnlineAdmissionLease, - onInvalidated: @escaping InvalidationHandler - ) async { - var nextOnlineCheck = lease.onlineValidatedAt? - .addingTimeInterval(Self.maximumOnlineSnapshotAge) - ?? clock.now().addingTimeInterval(Self.maximumOnlineSnapshotAge) - - while !Task.isCancelled { - let deadline = min(lease.expiresAt, nextOnlineCheck) - do { - try await clock.sleep(until: deadline) - try Task.checkCancellation() - } catch { - return - } - guard monitors[id] != nil else { return } - if clock.now() >= lease.expiresAt || isDenied(lease) { - await invalidate(id: id, onInvalidated: onInvalidated) - return - } - - switch await revalidate(lease) { - case let .active(fetchedAt): - nextOnlineCheck = fetchedAt.addingTimeInterval( - Self.maximumOnlineSnapshotAge - ) - case .connectivity: - nextOnlineCheck = clock.now().addingTimeInterval( - Self.maximumOnlineSnapshotAge - ) - case .terminal: - await invalidate(id: id, onInvalidated: onInvalidated) - return - } - } - } - - private func revalidate(_ lease: CmxIrohOnlineAdmissionLease) async -> Revalidation { - guard !isDenied(lease), clock.now() < lease.expiresAt else { - return .terminal - } - let revision = policyRevision - do { - guard let online = try await validatedSnapshot( - for: lease, - policyRevision: revision - ) else { - await invalidateDeniedMonitors() - return .terminal - } - guard clock.now() < lease.expiresAt else { - return .terminal - } - return .active(online.fetchedAt) - } catch { - return Self.isConnectivity(error) - && policyRevision == revision - && !isDenied(lease) - ? .connectivity - : .terminal - } - } - - private func validatedSnapshot( - for lease: CmxIrohOnlineAdmissionLease, - policyRevision expectedRevision: UInt64 - ) async throws -> Snapshot? { - let initial = try await currentSnapshot() - guard policyRevision == expectedRevision, !isDenied(lease) else { - return nil - } - if validate( - initial.snapshot.response, - lease: lease, - learnDenial: !initial.reusedCachedSnapshot - ) { - return initial.snapshot - } - guard initial.reusedCachedSnapshot else { return nil } - - let refreshed = try await currentSnapshot( - excludingCachedSnapshotID: initial.snapshot.id - ) - guard policyRevision == expectedRevision, - !isDenied(lease), - validate( - refreshed.snapshot.response, - lease: lease, - learnDenial: true - ) else { - return nil - } - return refreshed.snapshot - } - - private func currentSnapshot( - excludingCachedSnapshotID excludedSnapshotID: UUID? = nil - ) async throws -> SnapshotRead { - let now = clock.now() - if let snapshot, - snapshot.id != excludedSnapshotID, - now >= snapshot.fetchedAt, - now.timeIntervalSince(snapshot.fetchedAt) < Self.maximumOnlineSnapshotAge { - return SnapshotRead( - snapshot: snapshot, - reusedCachedSnapshot: excludedSnapshotID == nil - ) - } - let operation: Refresh - if let refresh { - operation = refresh - } else { - let id = UUID() - let broker = broker - operation = Refresh( - id: id, - task: Task { try await broker.discover() } - ) - refresh = operation - } - do { - let response = try await operation.task.value - let fetchedAt = clock.now() - let current = Snapshot( - id: operation.id, - response: response, - fetchedAt: fetchedAt - ) - if refresh?.id == operation.id { - refresh = nil - snapshot = current - } - return SnapshotRead(snapshot: current, reusedCachedSnapshot: false) - } catch { - if refresh?.id == operation.id { refresh = nil } - throw error - } - } - - private func validate( - _ response: CmxIrohDiscoveryResponse, - lease: CmxIrohOnlineAdmissionLease, - learnDenial: Bool - ) -> Bool { - guard response.routeContractVersion == routeContractVersion, - Set(response.relayFleet) == managedRelayURLs else { - return false - } - switch lease.authority { - case let .pairGrant(_, initiator, acceptor): - return validatePairGrantBindings( - response.bindings, - initiator: initiator, - acceptor: acceptor, - learnDenial: learnDenial - ) - case let .offlinePairing(initiator, acceptor): - return validateOfflineBindings( - response.bindings, - initiator: initiator, - acceptor: acceptor, - learnDenial: learnDenial - ) - } - } - - private func validatePairGrantBindings( - _ bindings: [CmxIrohBrokerBinding], - initiator: CmxIrohGrantPeer, - acceptor: CmxIrohGrantPeer, - learnDenial: Bool - ) -> Bool { - let initiatorMatches = bindings.filter { - CmxIrohGrantPeer(binding: $0) == initiator - } - let acceptorMatches = bindings.filter { - CmxIrohGrantPeer(binding: $0) == acceptor - } - let initiatorIdentityMatches = bindings.filter { - $0.endpointID == initiator.endpointID && $0.platform == initiator.platform - } - let acceptorIdentityMatches = bindings.filter { - $0.endpointID == acceptor.endpointID && $0.platform == acceptor.platform - } - let initiatorActive = initiatorMatches.count == 1 - && initiatorIdentityMatches.count == 1 - let acceptorActive = acceptorMatches.count == 1 - && acceptorIdentityMatches.count == 1 - && acceptorMatches[0].pairingEnabled - if learnDenial { - if !initiatorActive { deniedBindingIDs.insert(initiator.bindingID) } - if !acceptorActive { deniedBindingIDs.insert(acceptor.bindingID) } - } - return initiatorActive && acceptorActive - } - - private func validateOfflineBindings( - _ bindings: [CmxIrohBrokerBinding], - initiator: CmxIrohEndpointExpectation, - acceptor: CmxIrohEndpointExpectation, - learnDenial: Bool - ) -> Bool { - let initiatorMatches = bindings.filter { Self.matches($0, expectation: initiator) } - let acceptorMatches = bindings.filter { Self.matches($0, expectation: acceptor) } - let initiatorIdentityMatches = bindings.filter { - $0.endpointID == initiator.endpointID && $0.platform == initiator.platform - } - let acceptorIdentityMatches = bindings.filter { - $0.endpointID == acceptor.endpointID && $0.platform == acceptor.platform - } - let initiatorActive = initiatorMatches.count == 1 - && initiatorIdentityMatches.count == 1 - let acceptorActive = acceptorMatches.count == 1 - && acceptorIdentityMatches.count == 1 - && acceptorMatches[0].pairingEnabled - if learnDenial { - if !initiatorActive { deniedBindingIDs.insert(initiator.bindingID) } - if !acceptorActive { deniedBindingIDs.insert(acceptor.bindingID) } - } - return initiatorActive && acceptorActive - } - - private func isDenied(_ lease: CmxIrohOnlineAdmissionLease) -> Bool { - deniedBindingIDs.contains(lease.authority.initiatorBindingID) - || deniedBindingIDs.contains(lease.authority.acceptorBindingID) - } - - private func isExpired(_ lease: CmxIrohOnlineAdmissionLease) -> Bool { - lease.expiresAt <= clock.now() - } - - private func currentAcceptorExpectation() -> CmxIrohEndpointExpectation { - CmxIrohEndpointExpectation( - bindingID: acceptor.bindingID, - deviceID: acceptor.deviceID, - endpointID: acceptor.endpointID, - identityGeneration: acceptor.identityGeneration, - platform: acceptor.platform - ) - } - - private static func matches( - _ binding: CmxIrohBrokerBinding, - expectation: CmxIrohEndpointExpectation - ) -> Bool { - binding.bindingID == expectation.bindingID - && binding.deviceID == expectation.deviceID - && binding.endpointID == expectation.endpointID - && binding.identityGeneration == expectation.identityGeneration - && binding.platform == expectation.platform - } - - private func invalidate( - id: UUID, - onInvalidated: @escaping InvalidationHandler - ) async { - guard let monitor = monitors.removeValue(forKey: id) else { return } - monitor.connectionLifetimeTask?.cancel() - await onInvalidated() - } - - private func connectionClosed(id: UUID) { - guard let monitor = monitors.removeValue(forKey: id) else { return } - monitor.task?.cancel() - } - - private func invalidateDeniedMonitors() async { - let denied = monitors.filter { isDenied($0.value.lease) } - for id in denied.keys { monitors[id] = nil } - for monitor in denied.values { - monitor.task?.cancel() - monitor.connectionLifetimeTask?.cancel() - } - for monitor in denied.values { await monitor.onInvalidated() } - } - - private static func isConnectivity(_ error: any Error) -> Bool { - (error as? CmxIrohTrustBrokerClientError) == .connectivity - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocation.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocation.swift deleted file mode 100644 index 38da21bd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocation.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation - -/// A non-secret broker binding queued for revocation on its owning account. -public struct CmxIrohPendingRevocation: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case accountID - case tag - case bindingID - } - - /// The exact authenticated account allowed to revoke the binding. - public let accountID: String - - /// The build tag that created the binding. - public let tag: String - - /// The broker-owned lowercase binding UUID. - public let bindingID: String - - /// Creates a validated device-local revocation record. - /// - /// - Parameters: - /// - accountID: The exact authenticated account that owns the binding. - /// - tag: The safe build tag that created the binding. - /// - bindingID: The broker-owned lowercase binding UUID. - /// - Throws: ``CmxIrohPendingRevocationError/invalidRecord`` for malformed input. - public init(accountID: String, tag: String, bindingID: String) throws { - guard Self.isSafeAccountID(accountID), - Self.isSafeTag(tag), - Self.isCanonicalUUID(bindingID) else { - throw CmxIrohPendingRevocationError.invalidRecord - } - self.accountID = accountID - self.tag = tag - self.bindingID = bindingID - } - - /// Decodes and revalidates one device-local revocation record. - /// - /// - Parameter decoder: The decoder containing the stored record. - /// - Throws: ``CmxIrohPendingRevocationError/invalidRecord`` for malformed input. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - accountID: container.decode(String.self, forKey: .accountID), - tag: container.decode(String.self, forKey: .tag), - bindingID: container.decode(String.self, forKey: .bindingID) - ) - } - - static func isSafeAccountID(_ value: String) -> Bool { - (1 ... 1_024).contains(value.utf8.count) - && !value.unicodeScalars.contains(where: { - $0.value <= 0x1f || $0.value == 0x7f - }) - } - - static func isSafeTag(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 58, 95].contains(byte) - } - } - - private static func isCanonicalUUID(_ value: String) -> Bool { - UUID(uuidString: value)?.uuidString.lowercased() == value - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocationError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocationError.swift deleted file mode 100644 index 29d1b68f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocationError.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Failures at the device-local pending-revocation boundary. -public enum CmxIrohPendingRevocationError: Error, Equatable, Sendable { - /// A binding, account, or build-tag scope is malformed. - case invalidRecord - - /// Persisted state is corrupt, mismatched, or from an unsupported schema. - case invalidStoredState - - /// The bounded account outbox cannot accept another distinct binding. - case capacityExceeded -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocationOutbox.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocationOutbox.swift deleted file mode 100644 index 63d04ed2..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPendingRevocationOutbox.swift +++ /dev/null @@ -1,191 +0,0 @@ -import CryptoKit -import Foundation - -/// Durably queues binding revocations without retaining authentication tokens. -public actor CmxIrohPendingRevocationOutbox { - private static let currentVersion = 1 - private static let maximumEntryCount = 16 - private static let maximumEncodedByteCount = 64 * 1_024 - - private let secureStore: any CmxIrohSecureCredentialStoring - private var busyScopes: Set<String> = [] - private var scopeWaiters: [String: [CheckedContinuation<Void, Never>]] = [:] - - /// Creates an outbox backed by injected device-only secure storage. - /// - /// Production callers should inject a dedicated ``CmxIrohKeychainCredentialStore`` - /// service. Tests can inject an in-memory store without touching user state. - /// - /// - Parameter secureStore: Storage used only for pending revocation records. - public init(secureStore: any CmxIrohSecureCredentialStoring) { - self.secureStore = secureStore - } - - /// Idempotently persists a binding before local identity state is removed. - /// - /// - Parameter revocation: The account-and-tag-scoped binding to revoke. - /// - Throws: A validation, capacity, encoding, or secure-storage error. - public func enqueue(_ revocation: CmxIrohPendingRevocation) async throws { - let scope = Self.scope(revocation.accountID) - await acquire(scope: scope) - defer { release(scope: scope) } - var entries = try await load(accountID: revocation.accountID) - if let existing = entries.first(where: { - $0.bindingID == revocation.bindingID - }) { - guard existing == revocation else { - throw CmxIrohPendingRevocationError.invalidStoredState - } - return - } - guard entries.count < Self.maximumEntryCount else { - throw CmxIrohPendingRevocationError.capacityExceeded - } - entries.append(revocation) - try await persist(entries, accountID: revocation.accountID) - } - - /// Loads validated pending records for one exact authenticated account. - /// - /// - Parameter accountID: The account whose opaque Keychain scope is read. - /// - Returns: The insertion-ordered pending records for that account. - /// - Throws: A validation, decoding, or secure-storage error. - public func pending(accountID: String) async throws -> [CmxIrohPendingRevocation] { - guard CmxIrohPendingRevocation.isSafeAccountID(accountID) else { - throw CmxIrohPendingRevocationError.invalidRecord - } - let scope = Self.scope(accountID) - await acquire(scope: scope) - defer { release(scope: scope) } - return try await load(accountID: accountID) - } - - /// Revokes every pending binding owned by an account before registration. - /// - /// The current tag is attempted first, followed by older build tags. A - /// broker or persistence failure leaves the unconfirmed record durable and - /// stops the drain, so callers must not register or discover afterward. - /// - /// - Parameters: - /// - accountID: The currently authenticated account. - /// - tag: The build tag about to register. - /// - broker: An authenticated idempotent binding revoker. - /// - Throws: The first broker, validation, decoding, or persistence error. - public func revokePending( - accountID: String, - beforeRegisteringTag tag: String, - using broker: any CmxIrohBindingRevoking - ) async throws { - guard CmxIrohPendingRevocation.isSafeAccountID(accountID), - CmxIrohPendingRevocation.isSafeTag(tag) else { - throw CmxIrohPendingRevocationError.invalidRecord - } - let snapshot = try await pending(accountID: accountID) - let ordered = snapshot.filter { $0.tag == tag } - + snapshot.filter { $0.tag != tag } - for revocation in ordered { - try await broker.revoke(bindingID: revocation.bindingID) - - try await removeConfirmed(revocation) - } - } - - private func removeConfirmed( - _ revocation: CmxIrohPendingRevocation - ) async throws { - let scope = Self.scope(revocation.accountID) - await acquire(scope: scope) - defer { release(scope: scope) } - - // The broker call is an actor reentrancy point. Reload before the - // compare-remove so a concurrent enqueue cannot be overwritten. - var current = try await load(accountID: revocation.accountID) - current.removeAll { $0 == revocation } - try await persist(current, accountID: revocation.accountID) - } - - private func load(accountID: String) async throws -> [CmxIrohPendingRevocation] { - guard CmxIrohPendingRevocation.isSafeAccountID(accountID) else { - throw CmxIrohPendingRevocationError.invalidRecord - } - guard let data = try await secureStore.read(account: Self.scope(accountID)) else { - return [] - } - guard data.count <= Self.maximumEncodedByteCount, - let stored = try? JSONDecoder().decode( - CmxIrohStoredPendingRevocations.self, - from: data - ), - stored.version == Self.currentVersion, - stored.entries.count <= Self.maximumEntryCount, - stored.entries.allSatisfy({ $0.accountID == accountID }), - Set(stored.entries.map(\.bindingID)).count == stored.entries.count else { - throw CmxIrohPendingRevocationError.invalidStoredState - } - return stored.entries - } - - private func persist( - _ entries: [CmxIrohPendingRevocation], - accountID: String - ) async throws { - let scope = Self.scope(accountID) - guard !entries.isEmpty else { - try await secureStore.delete(account: scope) - return - } - guard entries.count <= Self.maximumEntryCount, - entries.allSatisfy({ $0.accountID == accountID }), - Set(entries.map(\.bindingID)).count == entries.count else { - throw CmxIrohPendingRevocationError.invalidStoredState - } - let data = try JSONEncoder().encode( - CmxIrohStoredPendingRevocations( - version: Self.currentVersion, - entries: entries - ) - ) - guard data.count <= Self.maximumEncodedByteCount else { - throw CmxIrohPendingRevocationError.capacityExceeded - } - try await secureStore.write( - data, - account: scope, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - } - - private static func scope(_ accountID: String) -> String { - let transcript = Data( - "cmux/iroh/pending-revocations/v1\0\(accountID)".utf8 - ) - return SHA256.hash(data: transcript) - .map { String(format: "%02x", $0) } - .joined() - } - - private func acquire(scope: String) async { - guard busyScopes.contains(scope) else { - busyScopes.insert(scope) - return - } - await withCheckedContinuation { continuation in - scopeWaiters[scope, default: []].append(continuation) - } - } - - private func release(scope: String) { - guard var waiters = scopeWaiters[scope], !waiters.isEmpty else { - busyScopes.remove(scope) - scopeWaiters.removeValue(forKey: scope) - return - } - let next = waiters.removeFirst() - if waiters.isEmpty { - scopeWaiters.removeValue(forKey: scope) - } else { - scopeWaiters[scope] = waiters - } - next.resume() - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPersistedRelayPreference.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPersistedRelayPreference.swift deleted file mode 100644 index fbc23110..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPersistedRelayPreference.swift +++ /dev/null @@ -1,81 +0,0 @@ -/// Last broker preference and the exact subset safely resolved on this device. -public struct CmxIrohPersistedRelayPreference: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case requested - case effective - case revision - case effectivePolicySequence - case staleRelayIDs - } - - /// Complete account configuration requested by the broker. - public let requested: CmxIrohAccountRelayConfiguration - - /// Preference subset that was last safely honored, or `nil` for direct-only. - public let effective: CmxIrohAccountRelayPreference? - - /// Monotonic broker preference revision. - public let revision: Int64 - - /// Signed policy sequence used to resolve the effective managed selection. - public let effectivePolicySequence: Int64? - - /// Requested managed IDs missing from that policy. - public let staleRelayIDs: Set<String> - - public init( - requested: CmxIrohAccountRelayConfiguration, - effective: CmxIrohAccountRelayPreference?, - revision: Int64, - effectivePolicySequence: Int64?, - staleRelayIDs: Set<String> - ) { - self.requested = requested - self.effective = effective - self.revision = revision - self.effectivePolicySequence = effectivePolicySequence - self.staleRelayIDs = staleRelayIDs - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if let configuration = try? container.decode( - CmxIrohAccountRelayConfiguration.self, - forKey: .requested - ) { - requested = configuration - } else { - let legacy = try container.decode( - CmxIrohAccountRelayPreference.self, - forKey: .requested - ) - switch legacy { - case .automatic: - requested = .automatic - case let .managed(ids): - requested = try .managed(ids) - case let .custom(relays): - requested = try .custom(relays) - } - } - effective = try container.decodeIfPresent( - CmxIrohAccountRelayPreference.self, - forKey: .effective - ) - revision = try container.decode(Int64.self, forKey: .revision) - effectivePolicySequence = try container.decodeIfPresent( - Int64.self, - forKey: .effectivePolicySequence - ) - staleRelayIDs = try container.decode(Set<String>.self, forKey: .staleRelayIDs) - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(requested, forKey: .requested) - try container.encodeIfPresent(effective, forKey: .effective) - try container.encode(revision, forKey: .revision) - try container.encodeIfPresent(effectivePolicySequence, forKey: .effectivePolicySequence) - try container.encode(staleRelayIDs, forKey: .staleRelayIDs) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPlatform.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPlatform.swift deleted file mode 100644 index 96722781..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPlatform.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// Platform role bound into Iroh registration and pairing credentials. -public enum CmxIrohPlatform: String, Codable, Equatable, Sendable { - /// A cmux host running on macOS. - case mac - - /// A cmux mobile client running on iOS. - case ios -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPooledByteTransport.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPooledByteTransport.swift deleted file mode 100644 index 67bc2bfb..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPooledByteTransport.swift +++ /dev/null @@ -1,89 +0,0 @@ -import CMUXMobileCore -import Foundation - -/// Projects a pooled admitted session's control lane through the legacy byte seam. -actor CmxIrohPooledByteTransport: CmxByteTransport { - private let request: CmxByteTransportRequest - private let pool: CmxIrohClientSessionPool - private let ownerID = UUID() - private var session: CmxIrohClientSession? - private var ownsControlSession = false - private var closed = false - - init(request: CmxByteTransportRequest, pool: CmxIrohClientSessionPool) { - self.request = request - self.pool = pool - } - - func connect() async throws { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - if session != nil { return } - let acquired = try await pool.acquireControlSession( - for: request, - ownerID: ownerID - ) - guard !closed else { - await pool.releaseControlSession(for: request, ownerID: ownerID) - throw CmxIrohByteTransportError.alreadyClosed - } - ownsControlSession = true - session = acquired - } - - func receive() async throws -> Data? { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - guard let session else { throw CmxIrohByteTransportError.notConnected } - do { - return try await session.receiveControl() - } catch { - await releaseOwnedControlSession( - reason: .controlReadFailed, - failure: DiagnosticFailureKind.classify(error) - ) - self.session = nil - throw error - } - } - - func send(_ data: Data) async throws { - guard !closed else { throw CmxIrohByteTransportError.alreadyClosed } - guard let session else { throw CmxIrohByteTransportError.notConnected } - do { - try await session.sendControl(data) - } catch { - await releaseOwnedControlSession( - reason: .controlWriteFailed, - failure: DiagnosticFailureKind.classify(error) - ) - self.session = nil - throw error - } - } - - func close() async { - guard !closed else { return } - closed = true - session = nil - // The mobile RPC session owns control framing and may leave a cancelled - // read or partial frame behind. Never hand that stream to a replacement - // RPC owner; close the peer session so the next control transport redials. - await releaseOwnedControlSession( - reason: .controlOwnerReleased, - failure: .none - ) - } - - private func releaseOwnedControlSession( - reason: DiagnosticSessionLifecycleKind, - failure: DiagnosticFailureKind - ) async { - guard ownsControlSession else { return } - ownsControlSession = false - await pool.releaseControlSession( - for: request, - ownerID: ownerID, - reason: reason, - failure: failure - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPreparedRegistration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPreparedRegistration.swift deleted file mode 100644 index 0d89ca04..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPreparedRegistration.swift +++ /dev/null @@ -1,23 +0,0 @@ -/// Canonical registration bytes retained across the challenge round trip. -public struct CmxIrohPreparedRegistration: Equatable, Sendable { - /// Request body for `/api/devices/iroh/challenge`. - public let challengeRequest: CmxIrohChallengeRequest - /// Base64url-encoded registration payload. - public let encodedPayload: String - /// SHA-256 of the decoded payload bytes. - public let payloadSHA256: String - /// Exact endpoint identity declared by the payload. - public let endpointID: String - - init( - challengeRequest: CmxIrohChallengeRequest, - encodedPayload: String, - payloadSHA256: String, - endpointID: String - ) { - self.challengeRequest = challengeRequest - self.encodedPayload = encodedPayload - self.payloadSHA256 = payloadSHA256 - self.endpointID = endpointID - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackAuthorization.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackAuthorization.swift deleted file mode 100644 index aed3c5e0..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackAuthorization.swift +++ /dev/null @@ -1,39 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// The exact path generation and private hints admitted for one fallback dial. -public struct CmxIrohPrivateFallbackAuthorization: Equatable, Sendable { - /// The path generation in which these hints were admitted. - public let networkPathSnapshot: CmxIrohNetworkPathSnapshot - - /// The exact fallback-only hints admitted in that generation. - public let pathHints: [CmxIrohPathHint] - - /// The local policy clock used to check hint freshness at admission. - public let admittedAt: Date - - /// Creates an authorization only for current hints on active profiles. - /// - /// - Throws: ``CmxIrohPrivateFallbackValidationError/authorizationMismatch`` - /// when a hint is public, stale, malformed, or outside the snapshot. - public init( - networkPathSnapshot: CmxIrohNetworkPathSnapshot, - pathHints: [CmxIrohPathHint], - admittedAt: Date - ) throws { - guard !pathHints.isEmpty, - pathHints.allSatisfy({ hint in - guard hint.privacyScope != .publicInternet, - hint.isUsable(at: admittedAt), - let profile = hint.networkProfile else { - return false - } - return networkPathSnapshot.activeNetworkProfiles.contains(profile) - }) else { - throw CmxIrohPrivateFallbackValidationError.authorizationMismatch - } - self.networkPathSnapshot = networkPathSnapshot - self.pathHints = pathHints - self.admittedAt = admittedAt - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackValidating.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackValidating.swift deleted file mode 100644 index 8b570434..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackValidating.swift +++ /dev/null @@ -1,7 +0,0 @@ -/// Revalidates an admitted private-network fallback immediately before dial. -public protocol CmxIrohPrivateFallbackValidating: Sendable { - /// Confirms the admitted generation, profiles, and hint freshness. - func validatePrivateFallback( - _ authorization: CmxIrohPrivateFallbackAuthorization - ) async throws -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackValidationError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackValidationError.swift deleted file mode 100644 index aaf2c916..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohPrivateFallbackValidationError.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Fail-closed reasons that prevent an explicit private-address dial. -public enum CmxIrohPrivateFallbackValidationError: Error, Equatable, Sendable { - /// No generation-aware network observer can validate the fallback. - case unavailable - - /// The authorization does not describe the session's exact private hints. - case authorizationMismatch - - /// The local network path changed after the hints were admitted. - case generationChanged - - /// An admitted provider-qualified profile is no longer active. - case profileUnavailable - - /// An admitted hint expired or no longer satisfies current wire policy. - case hintExpiredOrInvalid -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohProtocolConfiguration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohProtocolConfiguration.swift deleted file mode 100644 index ab78bf1d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohProtocolConfiguration.swift +++ /dev/null @@ -1,56 +0,0 @@ -public import Foundation - -/// Immutable limits and identifiers for one cmux Iroh protocol version. -public struct CmxIrohProtocolConfiguration: Equatable, Sendable { - /// Hard ceiling for opt-in client-created terminal and artifact streams. - public static let maximumClientApplicationLaneCount: UInt64 = 16 - - /// The ALPN negotiated by cmux Iroh endpoints. - public let alpn: Data - - /// The largest accepted stream-header frame, including its fixed prefix. - public let maximumHeaderByteCount: Int - - /// Additional client-created bidirectional lanes credited after admission. - /// - /// Production v1 leaves this at zero until an application lane owner is - /// installed. Test and future negotiated configurations may opt in up to - /// ``maximumClientApplicationLaneCount`` without weakening bootstrap limits. - public let maximumConcurrentClientApplicationLaneCount: UInt64 - - /// Whether an admitted connection may activate direct paths after both - /// peers have completed the authenticated admission barrier. - /// - /// Production keeps this enabled. Debug hosts can disable it to verify the - /// relay path without changing the ALPN or weakening admission. - public let allowsNATTraversalAfterAdmission: Bool - - /// Creates a protocol configuration. - /// - /// - Parameters: - /// - alpn: The application protocol identifier advertised through QUIC. - /// - maximumHeaderByteCount: The inclusive stream-header size limit. - public init( - alpn: Data, - maximumHeaderByteCount: Int, - maximumConcurrentClientApplicationLaneCount: UInt64 = 0, - allowsNATTraversalAfterAdmission: Bool = true - ) { - precondition( - maximumConcurrentClientApplicationLaneCount - <= Self.maximumClientApplicationLaneCount - ) - self.alpn = alpn - self.maximumHeaderByteCount = maximumHeaderByteCount - self.maximumConcurrentClientApplicationLaneCount = - maximumConcurrentClientApplicationLaneCount - self.allowsNATTraversalAfterAdmission = allowsNATTraversalAfterAdmission - } - - /// The production `cmux/mobile/1` protocol configuration. - public static let cmuxMobileV1 = CmxIrohProtocolConfiguration( - alpn: Data("cmux/mobile/1".utf8), - maximumHeaderByteCount: 16 * 1_024, - maximumConcurrentClientApplicationLaneCount: 0 - ) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRandomByteGenerating.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRandomByteGenerating.swift deleted file mode 100644 index 3f8edc2d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRandomByteGenerating.swift +++ /dev/null @@ -1,26 +0,0 @@ -public import Foundation -import Security - -/// Injectable cryptographic randomness boundary for one-use invitation proofs. -public protocol CmxIrohRandomByteGenerating: Sendable { - func randomBytes(count: Int) throws -> Data -} - -/// Security.framework-backed production randomness. -public struct CmxIrohSystemRandomByteGenerator: CmxIrohRandomByteGenerating { - public init() {} - - public func randomBytes(count: Int) throws -> Data { - guard count > 0 else { - throw CmxIrohOfflinePairingSessionError.randomnessUnavailable - } - var bytes = Data(count: count) - let status = bytes.withUnsafeMutableBytes { buffer in - SecRandomCopyBytes(kSecRandomDefault, count, buffer.baseAddress!) - } - guard status == errSecSuccess else { - throw CmxIrohOfflinePairingSessionError.randomnessUnavailable - } - return bytes - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohReceiveStream.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohReceiveStream.swift deleted file mode 100644 index 009d0b58..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohReceiveStream.swift +++ /dev/null @@ -1,16 +0,0 @@ -public import Foundation - -/// The readable half of one Iroh QUIC stream. -public protocol CmxIrohReceiveStream: Sendable { - /// Reads at most the requested number of bytes. - /// - /// - Parameter maximumByteCount: A positive per-read bound. - /// - Returns: Bytes, or `nil` after a clean peer finish. - /// - Throws: A transport error or `CancellationError`. - func receive(maximumByteCount: Int) async throws -> Data? - - /// Tells the peer to stop sending this stream. - /// - /// - Parameter errorCode: The application error code carried by QUIC. - func stop(errorCode: UInt64) async -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift deleted file mode 100644 index d13bb5bd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegisterRequest.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// Signed second leg of endpoint registration. -public struct CmxIrohRegisterRequest: Encodable, Equatable, Sendable { - /// One-use challenge UUID. - public let challengeId: String - /// Broker nonce copied verbatim from the challenge. - public let nonce: String - /// Base64url-encoded canonical payload bytes. - public let payload: String - /// Base64url Ed25519 signature over the registration transcript. - public let signature: String - - init(challengeID: String, nonce: String, payload: String, signature: String) { - challengeId = challengeID - self.nonce = nonce - self.payload = payload - self.signature = signature - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationError.swift deleted file mode 100644 index 0cea48b3..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationError.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// Local failures while constructing endpoint-authenticated registration. -public enum CmxIrohRegistrationError: Error, Equatable, Sendable { - /// A UUID, tag, display name, generation, capability, or hint is invalid. - case invalidPayload - - /// The encoded registration exceeds the broker request limit. - case payloadTooLarge - - /// The Iroh secret does not derive the declared EndpointID. - case endpointIdentityMismatch - - /// The broker challenge identifier or nonce is malformed. - case invalidChallenge -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationPayload.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationPayload.swift deleted file mode 100644 index 1db46342..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationPayload.swift +++ /dev/null @@ -1,148 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Endpoint-authenticated device state submitted to the Iroh trust broker. -public struct CmxIrohRegistrationPayload: Encodable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case routeContractVersion = "route_contract_version" - case deviceID = "deviceId" - case appInstanceID = "appInstanceId" - case tag - case platform - case displayName - case endpointID = "endpointId" - case identityGeneration - case pairingEnabled - case capabilities - case pathHints - case directPorts - } - - /// Current route-disclosure contract understood by the broker. - public let routeContractVersion: Int - /// Stable app-generated device UUID. - public let deviceID: String - /// Stable app-instance UUID for this installation and tag. - public let appInstanceID: String - /// Safe build or app-instance tag. - public let tag: String - /// Device role used by grant policy. - public let platform: CmxIrohPlatform - /// Optional user-facing device name. - public let displayName: String? - /// Canonical 64-character lowercase Iroh EndpointID. - public let endpointID: String - /// Generation changed only when endpoint identity rotates. - public let identityGeneration: Int - /// Whether this endpoint currently accepts pairing. - public let pairingEnabled: Bool - /// Bounded application capabilities advertised by this endpoint. - public let capabilities: [String] - /// Fresh reachability hints whose privacy scope remains explicit. - public let pathHints: [CmxIrohPathHint] - /// Endpoint-observed UDP ports, without any private IP address. - public let directPorts: CmxIrohDirectPorts? - - /// Creates a payload matching the broker contract. - /// - /// - Throws: ``CmxIrohRegistrationError/invalidPayload`` for any value the - /// broker would reject or any stale/unsafe hint. - public init( - deviceID: String, - appInstanceID: String, - tag: String, - platform: CmxIrohPlatform, - displayName: String? = nil, - endpointID: String, - identityGeneration: Int, - pairingEnabled: Bool, - capabilities: [String], - pathHints: [CmxIrohPathHint], - directPorts: CmxIrohDirectPorts? = nil, - now: Date = Date() - ) throws { - guard Self.isBrokerUUID(deviceID), - Self.isBrokerUUID(appInstanceID), - Self.isSafeToken(tag, maximum: 64), - (try? CmxIrohPeerIdentity(endpointID: endpointID)) != nil, - (1...Int(Int32.max)).contains(identityGeneration), - capabilities.count <= 32, - Set(capabilities).count == capabilities.count, - capabilities.allSatisfy({ Self.isSafeToken($0, maximum: 64) }), - pathHints.count <= 16, - pathHints.filter({ $0.kind == .relayURL }).count <= 2, - pathHints.allSatisfy({ Self.isBrokerHint($0, now: now) }) else { - throw CmxIrohRegistrationError.invalidPayload - } - if let displayName { - guard !displayName.isEmpty, - displayName.utf16.count <= 128, - !displayName.unicodeScalars.contains(where: { - $0.value <= 0x1f || $0.value == 0x7f - }) else { - throw CmxIrohRegistrationError.invalidPayload - } - } - routeContractVersion = 1 - self.deviceID = cmxCanonicalDeviceID(deviceID) - self.appInstanceID = appInstanceID.lowercased() - self.tag = tag - self.platform = platform - self.displayName = displayName - self.endpointID = endpointID - self.identityGeneration = identityGeneration - self.pairingEnabled = pairingEnabled - self.capabilities = capabilities - self.pathHints = pathHints - self.directPorts = directPorts - } - - private static func isBrokerUUID(_ value: String) -> Bool { - let bytes = Array(value.lowercased().utf8) - guard bytes.count == 36, - bytes[8] == 45, - bytes[13] == 45, - bytes[18] == 45, - bytes[23] == 45, - (49...56).contains(bytes[14]), - [56, 57, 97, 98].contains(bytes[19]) else { - return false - } - return bytes.enumerated().allSatisfy { index, byte in - if [8, 13, 18, 23].contains(index) { - return byte == 45 - } - return (48...57).contains(byte) || (97...102).contains(byte) - } - } - - private static func isSafeToken(_ value: String, maximum: Int) -> Bool { - guard !value.isEmpty, value.utf8.count <= maximum else { - return false - } - return value.utf8.allSatisfy { byte in - (48...57).contains(byte) - || (65...90).contains(byte) - || (97...122).contains(byte) - || byte == 45 - || byte == 46 - || byte == 58 - || byte == 95 - } - } - - private static func isBrokerHint(_ hint: CmxIrohPathHint, now: Date) -> Bool { - guard hint.kind != .relayIdentifier, - hint.isSafeForCurrentWireFormat, - hint.isUsable(at: now), - let observedAt = hint.observedAt, - let expiresAt = hint.expiresAt, - observedAt <= now.addingTimeInterval(5 * 60), - observedAt >= now.addingTimeInterval(-60 * 60), - expiresAt > now, - expiresAt <= observedAt.addingTimeInterval(60 * 60) else { - return false - } - return true - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift deleted file mode 100644 index 4678c3aa..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistrationSigner.swift +++ /dev/null @@ -1,127 +0,0 @@ -import CryptoKit -import Foundation - -/// Builds the two-leg registration proof using the Iroh EndpointID key. -public struct CmxIrohRegistrationSigner: Sendable { - private let secretKey: Data - private let endpointID: String - - /// Creates a signer and proves the supplied secret derives the endpoint. - /// - /// - Throws: ``CmxIrohRegistrationError/endpointIdentityMismatch`` when - /// route identity and signing identity differ. - public init(identity: CmxIrohIdentityMaterial, endpointID: String) throws { - let privateKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: identity.secretKey.bytes - ) - let derivedID = Self.hex(privateKey.publicKey.rawRepresentation) - guard derivedID == endpointID else { - throw CmxIrohRegistrationError.endpointIdentityMismatch - } - secretKey = identity.secretKey.bytes - self.endpointID = endpointID - } - - /// Canonically encodes payload bytes and constructs the challenge request. - public func prepare( - payload: CmxIrohRegistrationPayload - ) throws -> CmxIrohPreparedRegistration { - guard payload.endpointID == endpointID else { - throw CmxIrohRegistrationError.endpointIdentityMismatch - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - let payloadBytes = try encoder.encode(payload) - guard !payloadBytes.isEmpty, payloadBytes.count <= 32_768 else { - throw CmxIrohRegistrationError.payloadTooLarge - } - let payloadSHA256 = Self.hex(Data(SHA256.hash(data: payloadBytes))) - let challenge = CmxIrohChallengeRequest( - payload: payload, - payloadSHA256: payloadSHA256 - ) - return CmxIrohPreparedRegistration( - challengeRequest: challenge, - encodedPayload: Self.base64URL(payloadBytes), - payloadSHA256: payloadSHA256, - endpointID: endpointID - ) - } - - /// Signs the exact broker challenge and prepared payload hash. - public func sign( - prepared: CmxIrohPreparedRegistration, - challenge: CmxIrohChallengeResponse - ) throws -> CmxIrohRegisterRequest { - guard prepared.endpointID == endpointID, - Self.isBrokerUUID(challenge.challengeID), - let nonce = Self.decodeBase64URL(challenge.nonce), - nonce.count == 32 else { - throw CmxIrohRegistrationError.invalidChallenge - } - let challengeID = challenge.challengeID.lowercased() - let transcript = Data( - "cmux/iroh/device-registration/v1\n\(challengeID)\n\(challenge.nonce)\n\(prepared.payloadSHA256)".utf8 - ) - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: secretKey) - let signature = try privateKey.signature(for: transcript) - return CmxIrohRegisterRequest( - challengeID: challengeID, - nonce: challenge.nonce, - payload: prepared.encodedPayload, - signature: Self.base64URL(signature) - ) - } - - private static func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - private static func decodeBase64URL(_ value: String) -> Data? { - guard !value.isEmpty, - value.utf8.allSatisfy({ byte in - (48...57).contains(byte) - || (65...90).contains(byte) - || (97...122).contains(byte) - || byte == 45 - || byte == 95 - }) else { - return nil - } - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - let base64 = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - guard let decoded = Data(base64Encoded: base64), - base64URL(decoded) == value else { - return nil - } - return decoded - } - - private static func hex(_ data: Data) -> String { - data.map { String(format: "%02x", $0) }.joined() - } - - private static func isBrokerUUID(_ value: String) -> Bool { - let bytes = Array(value.lowercased().utf8) - guard bytes.count == 36, - bytes[8] == 45, - bytes[13] == 45, - bytes[18] == 45, - bytes[23] == 45, - (49...56).contains(bytes[14]), - [56, 57, 97, 98].contains(bytes[19]) else { - return false - } - return bytes.enumerated().allSatisfy { index, byte in - [8, 13, 18, 23].contains(index) - ? byte == 45 - : (48...57).contains(byte) || (97...102).contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextError.swift deleted file mode 100644 index ade8f262..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextError.swift +++ /dev/null @@ -1,12 +0,0 @@ -/// Authenticated discovery and pair-grant resolution failures. -public enum CmxIrohRegistryContextError: Error, Equatable, Sendable { - case unsupportedRoute - case incompatibleContract - case relayFleetMismatch - case localBindingUnavailable - case targetBindingUnavailable - case targetDeviceMismatch - case targetNotPairable - case invalidGrantExpiry - case dialPlanUnavailable -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift deleted file mode 100644 index 5d27e54f..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +++ /dev/null @@ -1,634 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Resolves fresh same-account reachability and a locally verified pair grant per dial. -public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider { - public typealias LANFallbackProvider = @Sendable ( - _ target: CmxIrohBrokerBindingMetadata, - _ authenticatedBindings: [CmxIrohBrokerBindingMetadata], - _ rendezvous: CmxIrohLANRendezvous - ) async -> [CmxIrohPathHint] - public typealias CustomPrivateFallbackProvider = @Sendable ( - _ expectedMacDeviceID: String - ) async -> [CmxIrohCustomPrivatePathBootstrap] - - let supervisor: CmxIrohEndpointSupervisor - let broker: any CmxIrohRegistryServing - var localBindingExpectation: CmxIrohLocalBindingExpectation - var managedRelayURLs: Set<String> - var allowedRouteRelayURLs: Set<String> - let networkPathSnapshot: (@Sendable () async throws -> CmxIrohNetworkPathSnapshot)? - var offlinePolicy: CmxIrohClientOfflinePolicyContext? - let lanFallback: LANFallbackProvider? - let customPrivateFallback: CustomPrivateFallbackProvider? - let verifier: CmxIrohGrantVerifier - let now: @Sendable () -> Date - var grantCache: [CmxIrohPeerIdentity: CmxIrohRegistryGrantCache] = [:] - var pairGrantRetryDeadline: (code: String?, date: Date)? - var lanAuthorities: [CmxIrohPeerIdentity: CmxIrohRegistryLANAuthority] = [:] - - /// Creates a public-route provider from the generation-less seam. - public init( - supervisor: CmxIrohEndpointSupervisor, - broker: any CmxIrohRegistryServing, - localBindingExpectation: CmxIrohLocalBindingExpectation, - managedRelayURLs: Set<String>, - allowedRouteRelayURLs: Set<String>? = nil, - activeNetworkProfiles: @escaping @Sendable () async -> Set<CmxIrohNetworkProfileKey>, - offlinePolicy: CmxIrohClientOfflinePolicyContext? = nil, - lanFallback: LANFallbackProvider? = nil, - customPrivateFallback: CustomPrivateFallbackProvider? = nil, - verifier: CmxIrohGrantVerifier = CmxIrohGrantVerifier(), - now: @escaping @Sendable () -> Date = { Date() } - ) { - self.supervisor = supervisor - self.broker = broker - self.localBindingExpectation = localBindingExpectation - self.managedRelayURLs = managedRelayURLs - self.allowedRouteRelayURLs = allowedRouteRelayURLs ?? managedRelayURLs - _ = activeNetworkProfiles - networkPathSnapshot = nil - self.offlinePolicy = offlinePolicy - self.lanFallback = lanFallback - self.customPrivateFallback = customPrivateFallback - self.verifier = verifier - self.now = now - } - - /// Creates a provider with generation-aware private-network validation. - public init( - supervisor: CmxIrohEndpointSupervisor, - broker: any CmxIrohRegistryServing, - localBindingExpectation: CmxIrohLocalBindingExpectation, - managedRelayURLs: Set<String>, - allowedRouteRelayURLs: Set<String>? = nil, - networkPathSnapshot: @escaping @Sendable () async throws -> CmxIrohNetworkPathSnapshot, - offlinePolicy: CmxIrohClientOfflinePolicyContext? = nil, - lanFallback: LANFallbackProvider? = nil, - customPrivateFallback: CustomPrivateFallbackProvider? = nil, - verifier: CmxIrohGrantVerifier = CmxIrohGrantVerifier(), - now: @escaping @Sendable () -> Date = { Date() } - ) { - self.supervisor = supervisor - self.broker = broker - self.localBindingExpectation = localBindingExpectation - self.managedRelayURLs = managedRelayURLs - self.allowedRouteRelayURLs = allowedRouteRelayURLs ?? managedRelayURLs - self.networkPathSnapshot = networkPathSnapshot - self.offlinePolicy = offlinePolicy - self.lanFallback = lanFallback - self.customPrivateFallback = customPrivateFallback - self.verifier = verifier - self.now = now - } - - public func context( - for request: CmxByteTransportRequest - ) async throws -> CmxIrohClientContext { - let route = request.route - guard route.kind == .iroh, - request.authorizationMode == .transportAdmission, - case let .peer(targetIdentity, routeHints) = route.endpoint else { - throw CmxIrohRegistryContextError.unsupportedRoute - } - lanAuthorities.removeValue(forKey: targetIdentity) - let endpoint = try await supervisor.activeEndpoint() - let localIdentity = await endpoint.identity() - guard localBindingExpectation.platform == .ios, - localBindingExpectation.endpointID == localIdentity else { - throw CmxIrohRegistryContextError.localBindingUnavailable - } - let discovery: CmxIrohDiscoveryResponse - do { - discovery = try await broker.discover() - } catch { - let clock = now() - guard Self.isConnectivity(error), - let cached = try await cachedPolicy( - for: request, - confirmedDiscovery: nil, - at: clock - ) else { - throw error - } - rememberCachedLANAuthority(cached) - return try await context( - targetBinding: cached.targetBinding, - routeHints: routeHints, - pairGrantToken: cached.pairGrant.grant, - at: clock - ) - } - guard discovery.routeContractVersion == 1 else { - throw CmxIrohRegistryContextError.incompatibleContract - } - guard Set(discovery.relayFleet) == managedRelayURLs else { - throw CmxIrohRegistryContextError.relayFleetMismatch - } - lanAuthorities.removeAll(keepingCapacity: false) - let localMatches = discovery.bindings.filter { - localBindingExpectation.matches($0) - } - guard localMatches.count == 1, let localBinding = localMatches.first else { - throw CmxIrohRegistryContextError.localBindingUnavailable - } - let targetMatches = discovery.bindings.filter { - $0.endpointID == targetIdentity && $0.platform == .mac - } - guard targetMatches.count == 1, let targetBinding = targetMatches.first else { - throw CmxIrohRegistryContextError.targetBindingUnavailable - } - guard let expectedPeerDeviceID = request.expectedPeerDeviceID, - CmxIrohDeviceID(expectedPeerDeviceID) - == CmxIrohDeviceID(targetBinding.deviceID) else { - throw CmxIrohRegistryContextError.targetDeviceMismatch - } - guard targetBinding.pairingEnabled else { - throw CmxIrohRegistryContextError.targetNotPairable - } - replaceLANAuthorities(with: discovery) - let initiator = CmxIrohGrantPeer(binding: localBinding) - let acceptor = CmxIrohGrantPeer(binding: targetBinding) - let clock = now() - let pairGrant: CmxIrohPairGrantResponse - do { - pairGrant = try await grant( - initiator: initiator, - acceptor: acceptor, - targetIdentity: targetIdentity, - keys: discovery.grantVerificationKeys, - now: clock - ) - } catch { - guard Self.isConnectivity(error), - let cached = try await cachedPolicy( - for: request, - confirmedDiscovery: discovery, - at: clock - ) else { - throw error - } - rememberCachedLANAuthority(cached, bindings: discovery.bindings) - return try await context( - targetBinding: cached.targetBinding, - routeHints: routeHints, - pairGrantToken: cached.pairGrant.grant, - at: clock - ) - } - if let offlinePolicy { - try? await offlinePolicy.cache.save( - localBinding: localBinding, - targetBinding: targetBinding, - discovery: discovery, - pairGrant: pairGrant, - for: offlinePolicy.expectation, - now: clock - ) - } - return try await context( - targetBinding: targetBinding, - routeHints: routeHints, - pairGrantToken: pairGrant.grant, - at: clock - ) - } - - /// Replaces broker-verified route policy without replacing this provider's - /// grant cache or server retry deadline. Runtime registration refreshes are - /// frequent, while pair grants remain valid for days and broker rate limits - /// apply across those refresh generations. - func updatePolicy( - localBindingExpectation: CmxIrohLocalBindingExpectation, - managedRelayURLs: Set<String>, - allowedRouteRelayURLs: Set<String>, - offlinePolicy: CmxIrohClientOfflinePolicyContext? - ) { - if self.localBindingExpectation != localBindingExpectation { - grantCache.removeAll(keepingCapacity: false) - lanAuthorities.removeAll(keepingCapacity: false) - } - self.localBindingExpectation = localBindingExpectation - self.managedRelayURLs = managedRelayURLs - self.allowedRouteRelayURLs = allowedRouteRelayURLs - self.offlinePolicy = offlinePolicy - } - - private func context( - targetBinding: CmxIrohBrokerBinding, - routeHints: [CmxIrohPathHint], - pairGrantToken: String, - at clock: Date - ) async throws -> CmxIrohClientContext { - let targetIdentity = targetBinding.endpointID - var routeHints = authoritativePrivateRouteHints( - routeHints, - targetBinding: targetBinding, - at: clock - ) - routeHints.append(contentsOf: await customPrivateRouteHints( - targetBinding: targetBinding, - at: clock - )) - let pathSnapshot = try await availableNetworkPathSnapshot( - for: targetBinding.pathHints + routeHints, - at: clock - ) - let profiles = pathSnapshot?.activeNetworkProfiles ?? [] - let hints = CmxIrohRegistryPathMerger.merge( - primary: targetBinding.pathHints, - fallback: routeHints, - at: clock, - managedRelayURLs: allowedRouteRelayURLs, - activeNetworkProfiles: profiles - ) - let endpointAddress = CmxAttachEndpoint.peer( - identity: targetIdentity, - pathHints: hints - ) - guard let dialPlan = endpointAddress.irohDialPlan( - at: clock, - managedRelayURLs: allowedRouteRelayURLs, - activeNetworkProfiles: profiles - ) else { - throw CmxIrohRegistryContextError.dialPlanUnavailable - } - let fallbackAuthorization: CmxIrohPrivateFallbackAuthorization? - if let pathSnapshot, !dialPlan.privateFallbackPaths.isEmpty { - fallbackAuthorization = try CmxIrohPrivateFallbackAuthorization( - networkPathSnapshot: pathSnapshot, - pathHints: dialPlan.privateFallbackPaths, - admittedAt: clock - ) - } else { - fallbackAuthorization = nil - } - return CmxIrohClientContext( - dialPlan: dialPlan, - credential: try .pairGrant(pairGrantToken), - privateFallbackAuthorization: fallbackAuthorization - ) - } - - /// Replaces legacy TCP-derived VPN ports with the endpoint-signed Iroh UDP - /// port for the same address family. Private IPs stay local, while stale or - /// incomplete broker metadata removes the hint instead of guessing. - private func authoritativePrivateRouteHints( - _ hints: [CmxIrohPathHint], - targetBinding: CmxIrohBrokerBinding, - at clock: Date - ) -> [CmxIrohPathHint] { - let lastSeenAt = CmxIrohISO8601Date.parse(targetBinding.lastSeenAt) - let portsAreFresh = lastSeenAt.map { - $0 <= clock.addingTimeInterval(CmxIrohPathHint.maximumObservationClockSkew) - && $0 >= clock.addingTimeInterval(-CmxIrohPathHint.maximumPrivateHintTTL) - } ?? false - let directPorts = portsAreFresh ? targetBinding.directPorts : nil - return hints.compactMap { hint in - guard hint.kind == .directAddress, - hint.privacyScope != .publicInternet, - hint.source == .tailscale || hint.source == .customVPN else { - return hint - } - return directPorts?.replacingPort(in: hint) - } - } - - /// Resolves explicit addresses only after broker discovery authenticated - /// this exact Mac tuple. The broker's current UDP port is authoritative; - /// the configured address contributes reachability only. - private func customPrivateRouteHints( - targetBinding: CmxIrohBrokerBinding, - at clock: Date - ) async -> [CmxIrohPathHint] { - guard let customPrivateFallback, - let directPorts = freshDirectPorts( - targetBinding: targetBinding, - at: clock - ) else { return [] } - let configured = await customPrivateFallback(targetBinding.deviceID) - var hints: [CmxIrohPathHint] = [] - for path in configured.prefix(CmxAttachEndpoint.maximumIrohPathHintCount) { - let port: UInt16? - switch path.address.family { - case .ipv4: port = directPorts.ipv4 - case .ipv6: port = directPorts.ipv6 - } - guard let port, - let hint = try? CmxIrohPathHint( - kind: .directAddress, - value: path.address.socketAddress(port: port), - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: clock, - expiresAt: clock.addingTimeInterval( - CmxIrohPathHint.maximumPrivateHintTTL - ), - networkProfile: path.networkProfile - ), - !hints.contains(hint) else { continue } - hints.append(hint) - } - return hints - } - - private func freshDirectPorts( - targetBinding: CmxIrohBrokerBinding, - at clock: Date - ) -> CmxIrohDirectPorts? { - guard let lastSeenAt = CmxIrohISO8601Date.parse(targetBinding.lastSeenAt), - lastSeenAt <= clock.addingTimeInterval( - CmxIrohPathHint.maximumObservationClockSkew - ), - lastSeenAt >= clock.addingTimeInterval( - -CmxIrohPathHint.maximumPrivateHintTTL - ) else { return nil } - return targetBinding.directPorts - } - - private func cachedPolicy( - for request: CmxByteTransportRequest, - confirmedDiscovery: CmxIrohDiscoveryResponse?, - at clock: Date - ) async throws -> CmxIrohCachedClientPolicy? { - guard let offlinePolicy else { return nil } - return try await offlinePolicy.cache.load( - for: request, - localBinding: offlinePolicy.localBinding, - expectation: offlinePolicy.expectation, - confirmedDiscovery: confirmedDiscovery, - now: clock - ) - } - - public func contextWithPrivateFallback( - for request: CmxByteTransportRequest, - basedOn context: CmxIrohClientContext - ) async throws -> CmxIrohClientContext { - guard request.route.kind == .iroh, - request.authorizationMode == .transportAdmission, - let expectedDeviceID = request.expectedPeerDeviceID, - case let .peer(targetIdentity, _) = request.route.endpoint, - let authority = lanAuthorities[targetIdentity], - authority.target.endpointID == targetIdentity, - CmxIrohDeviceID(authority.target.deviceID) - == CmxIrohDeviceID(expectedDeviceID) else { - return context - } - let lanHints = await localFallbackHints( - target: authority.target, - bindings: authority.bindings, - rendezvous: authority.rendezvous - ) - guard !lanHints.isEmpty else { return context } - let clock = now() - let combined = CmxIrohRegistryPathMerger.merge( - primary: context.dialPlan.publicPaths + context.dialPlan.privateFallbackPaths, - fallback: lanHints, - at: clock, - managedRelayURLs: allowedRouteRelayURLs, - activeNetworkProfiles: (try await availableNetworkPathSnapshot( - for: lanHints, - at: clock - ))?.activeNetworkProfiles ?? [] - ) - let pathSnapshot = try await availableNetworkPathSnapshot( - for: combined, - at: clock - ) - let profiles = pathSnapshot?.activeNetworkProfiles ?? [] - guard let dialPlan = CmxAttachEndpoint.peer( - identity: targetIdentity, - pathHints: combined - ).irohDialPlan( - at: clock, - managedRelayURLs: allowedRouteRelayURLs, - activeNetworkProfiles: profiles - ), dialPlan.publicPaths == context.dialPlan.publicPaths else { - return context - } - let authorization: CmxIrohPrivateFallbackAuthorization? - if let pathSnapshot, !dialPlan.privateFallbackPaths.isEmpty { - authorization = try CmxIrohPrivateFallbackAuthorization( - networkPathSnapshot: pathSnapshot, - pathHints: dialPlan.privateFallbackPaths, - admittedAt: clock - ) - } else { - authorization = nil - } - return CmxIrohClientContext( - dialPlan: dialPlan, - credential: context.credential, - privateFallbackAuthorization: authorization - ) - } - - private func localFallbackHints( - target: CmxIrohBrokerBinding, - bindings: [CmxIrohBrokerBinding], - rendezvous: CmxIrohLANRendezvous - ) async -> [CmxIrohPathHint] { - guard let lanFallback else { return [] } - let result = await lanFallback( - CmxIrohBrokerBindingMetadata(binding: target), - bindings.map(CmxIrohBrokerBindingMetadata.init(binding:)), - rendezvous - ) - return Array(result.prefix(CmxIrohLANTXTRecord.maximumAddressCount)).filter { - $0.kind == .directAddress - && $0.source == .lan - && $0.privacyScope == .localNetwork - && $0.networkProfile?.source == .lan - } - } - - private func replaceLANAuthorities(with discovery: CmxIrohDiscoveryResponse) { - var replacement: [CmxIrohPeerIdentity: CmxIrohRegistryLANAuthority] = [:] - let pairableMacs = discovery.bindings.filter { - $0.platform == .mac && $0.pairingEnabled - } - let counts = Dictionary(grouping: pairableMacs, by: \.endpointID).mapValues(\.count) - for target in pairableMacs.prefix(CmxIrohDiscoveryResponse.maximumBindingCount) - where counts[target.endpointID] == 1 { - replacement[target.endpointID] = CmxIrohRegistryLANAuthority( - target: target, - bindings: discovery.bindings, - rendezvous: discovery.lanRendezvous - ) - } - lanAuthorities = replacement - } - - private func rememberCachedLANAuthority( - _ policy: CmxIrohCachedClientPolicy, - bindings: [CmxIrohBrokerBinding]? = nil - ) { - guard policy.targetBinding.platform == .mac, - policy.targetBinding.pairingEnabled else { return } - lanAuthorities[policy.targetBinding.endpointID] = CmxIrohRegistryLANAuthority( - target: policy.targetBinding, - bindings: bindings ?? [policy.targetBinding], - rendezvous: policy.lanRendezvous - ) - if lanAuthorities.count > CmxIrohDiscoveryResponse.maximumBindingCount { - let keep = Set(lanAuthorities.keys.sorted { - $0.endpointID < $1.endpointID - }.prefix(CmxIrohDiscoveryResponse.maximumBindingCount)) - lanAuthorities = lanAuthorities.filter { keep.contains($0.key) } - } - } - - public func validatePrivateFallback( - _ authorization: CmxIrohPrivateFallbackAuthorization - ) async throws { - guard let networkPathSnapshot else { - throw CmxIrohPrivateFallbackValidationError.unavailable - } - try Task.checkCancellation() - let clock = now() - guard authorization.pathHints.allSatisfy({ hint in - hint.privacyScope != .publicInternet && hint.isUsable(at: clock) - }) else { - throw CmxIrohPrivateFallbackValidationError.hintExpiredOrInvalid - } - let currentSnapshot: CmxIrohNetworkPathSnapshot - do { - currentSnapshot = try await networkPathSnapshot() - } catch is CancellationError { - throw CancellationError() - } catch { - throw CmxIrohPrivateFallbackValidationError.unavailable - } - try Task.checkCancellation() - guard currentSnapshot.generation == authorization.networkPathSnapshot.generation else { - throw CmxIrohPrivateFallbackValidationError.generationChanged - } - guard authorization.pathHints.allSatisfy({ hint in - guard let profile = hint.networkProfile else { return false } - return currentSnapshot.activeNetworkProfiles.contains(profile) - }) else { - throw CmxIrohPrivateFallbackValidationError.profileUnavailable - } - } - - public func invalidateGrant(for identity: CmxIrohPeerIdentity? = nil) { - if let identity { - grantCache.removeValue(forKey: identity) - } else { - grantCache.removeAll(keepingCapacity: false) - } - } - - private func grant( - initiator: CmxIrohGrantPeer, - acceptor: CmxIrohGrantPeer, - targetIdentity: CmxIrohPeerIdentity, - keys: CmxIrohGrantVerificationKeySet, - now: Date - ) async throws -> CmxIrohPairGrantResponse { - let refreshBoundary = now.addingTimeInterval(72 * 60 * 60) - if let cached = grantCache[targetIdentity], - cached.initiator == initiator, - cached.acceptor == acceptor, - cached.expiresAt > refreshBoundary { - do { - _ = try verifier.verifyPairGrant( - cached.response.grant, - keys: keys, - initiator: initiator, - acceptor: acceptor, - now: now - ) - try Self.requireMatchingGrantExpiry( - cached.response, - signedExpiry: cached.expiresAt, - now: now - ) - return cached.response - } catch { - grantCache.removeValue(forKey: targetIdentity) - } - } - if let deadline = pairGrantRetryDeadline { - let remaining = Int(ceil(deadline.date.timeIntervalSince(now))) - if remaining > 0 { - throw CmxIrohTrustBrokerClientError.rateLimited( - code: deadline.code, - retryAfterSeconds: remaining - ) - } - pairGrantRetryDeadline = nil - } - let response: CmxIrohPairGrantResponse - do { - response = try await broker.issuePairGrant( - initiatorBindingID: initiator.bindingID, - acceptorBindingID: acceptor.bindingID - ) - pairGrantRetryDeadline = nil - } catch let error as CmxIrohTrustBrokerClientError { - if case let .rateLimited(code, retryAfterSeconds) = error { - pairGrantRetryDeadline = ( - code: code, - date: now.addingTimeInterval(TimeInterval(max(1, retryAfterSeconds))) - ) - } - throw error - } - let claims = try verifier.verifyPairGrant( - response.grant, - keys: keys, - initiator: initiator, - acceptor: acceptor, - now: now - ) - let signedExpiresAt = Date(timeIntervalSince1970: TimeInterval(claims.expiresAt)) - try Self.requireMatchingGrantExpiry( - response, - signedExpiry: signedExpiresAt, - now: now - ) - grantCache[targetIdentity] = CmxIrohRegistryGrantCache( - initiator: initiator, - acceptor: acceptor, - response: response, - expiresAt: signedExpiresAt - ) - return response - } - - private func availableNetworkPathSnapshot( - for hints: [CmxIrohPathHint], - at clock: Date - ) async throws -> CmxIrohNetworkPathSnapshot? { - guard hints.contains(where: { - $0.privacyScope != .publicInternet && $0.isUsable(at: clock) - }), let networkPathSnapshot else { - return nil - } - do { - return try await networkPathSnapshot() - } catch is CancellationError { - throw CancellationError() - } catch { - return nil - } - } - - private static func requireMatchingGrantExpiry( - _ response: CmxIrohPairGrantResponse, - signedExpiry: Date, - now: Date - ) throws { - guard let responseExpiry = CmxIrohISO8601Date.parse(response.expiresAt), - abs(responseExpiry.timeIntervalSince(signedExpiry)) < 1, - signedExpiry > now else { - throw CmxIrohRegistryContextError.invalidGrantExpiry - } - } - - private static func isConnectivity(_ error: any Error) -> Bool { - (error as? CmxIrohTrustBrokerClientError) == .connectivity - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextState.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextState.swift deleted file mode 100644 index 47c89f05..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextState.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -struct CmxIrohRegistryGrantCache: Sendable { - let initiator: CmxIrohGrantPeer - let acceptor: CmxIrohGrantPeer - let response: CmxIrohPairGrantResponse - let expiresAt: Date -} - -struct CmxIrohRegistryLANAuthority: Sendable { - let target: CmxIrohBrokerBinding - let bindings: [CmxIrohBrokerBinding] - let rendezvous: CmxIrohLANRendezvous -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryPathMerger.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryPathMerger.swift deleted file mode 100644 index a760696e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryPathMerger.swift +++ /dev/null @@ -1,58 +0,0 @@ -internal import CMUXMobileCore -import Foundation - -struct CmxIrohRegistryPathMerger { - static func merge( - primary: [CmxIrohPathHint], - fallback: [CmxIrohPathHint], - at now: Date, - managedRelayURLs: Set<String>, - activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> - ) -> [CmxIrohPathHint] { - var result: [CmxIrohPathHint] = [] - for hint in primary + fallback where hint.isUsable(at: now) { - guard isEligible( - hint, - managedRelayURLs: managedRelayURLs, - activeNetworkProfiles: activeNetworkProfiles - ) else { - continue - } - if !result.contains(where: { sameRoute($0, hint) }) { - result.append(hint) - } - if result.count == CmxAttachEndpoint.maximumIrohPathHintCount { break } - } - return result - } - - private static func isEligible( - _ hint: CmxIrohPathHint, - managedRelayURLs: Set<String>, - activeNetworkProfiles: Set<CmxIrohNetworkProfileKey> - ) -> Bool { - if hint.privacyScope != .publicInternet { - guard let profile = hint.networkProfile else { return false } - return activeNetworkProfiles.contains(profile) - } - switch hint.kind { - case .directAddress: - return true - case .relayURL: - return managedRelayURLs.contains(hint.value) - case .relayIdentifier: - return false - } - } - - private static func sameRoute( - _ left: CmxIrohPathHint, - _ right: CmxIrohPathHint - ) -> Bool { - left.kind == right.kind - && left.value == right.value - && left.source == right.source - && left.privacyScope == right.privacyScope - && left.networkProfile == right.networkProfile - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryServing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryServing.swift deleted file mode 100644 index e9dc2283..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryServing.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// Narrow trust-broker boundary required to resolve one authenticated dial. -public protocol CmxIrohRegistryServing: CmxIrohDiscoveryServing { - /// Issues a grant for one exact iOS initiator and Mac acceptor binding. - func issuePairGrant( - initiatorBindingID: String, - acceptorBindingID: String - ) async throws -> CmxIrohPairGrantResponse -} - -extension CmxIrohTrustBrokerClient: CmxIrohRegistryServing {} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift deleted file mode 100644 index c7f274a5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayBootstrapResponse.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Relay credential and signed policy returned by one bootstrap request. -public struct CmxIrohRelayBootstrapResponse: Equatable, Sendable { - /// Managed relay credential, absent for custom or direct-only preferences. - public let relayToken: CmxIrohRelayTokenResponse? - - /// Signed policy and account preference resolved by the broker. - public let relayPolicy: CmxIrohRelayPolicyResponse - - /// Creates one validated bootstrap response. - public init( - relayToken: CmxIrohRelayTokenResponse?, - relayPolicy: CmxIrohRelayPolicyResponse - ) { - self.relayToken = relayToken - self.relayPolicy = relayPolicy - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayClock.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayClock.swift deleted file mode 100644 index 112bd63c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayClock.swift +++ /dev/null @@ -1,22 +0,0 @@ -public import Foundation - -/// Clock boundary used by cancellable relay refresh scheduling. -public protocol CmxIrohRelayClock: Sendable { - func now() -> Date - func sleep(until deadline: Date) async throws -} - -/// Wall-clock to monotonic-delay adapter for production relay refreshes. -public struct CmxIrohSystemRelayClock: CmxIrohRelayClock { - public init() {} - - public func now() -> Date { - Date() - } - - public func sleep(until deadline: Date) async throws { - let delay = deadline.timeIntervalSinceNow - guard delay > 0 else { return } - try await Task<Never, Never>.sleep(for: .seconds(delay)) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift deleted file mode 100644 index 0ec058c4..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfiguration.swift +++ /dev/null @@ -1,87 +0,0 @@ -public import Foundation - -/// A short-lived endpoint-scoped credential for one managed relay. -public struct CmxIrohRelayConfiguration: Equatable, Sendable { - /// The exact canonical relay URL accepted by the app configuration. - public let url: String - - /// The compact JWT, or pre-migration RCAN, used as Iroh's relay auth token. - public let token: String - - /// The hard time after which the relay must reject the token. - public let expiresAt: Date - - /// The time at which cmux should obtain a replacement before expiry. - public let refreshAfter: Date - - /// Creates a validated managed-relay configuration. - /// - /// - Parameters: - /// - url: A canonical HTTPS relay origin with a trailing slash. - /// - token: A compact Base64URL JWT or legacy lowercase Base32 RCAN. - /// - expiresAt: The provider-enforced token expiry. - /// - refreshAfter: A replacement time strictly before expiry. - /// - now: The validation time, injected for deterministic tests. - /// - Throws: ``CmxIrohRelayConfigurationError`` for malformed or expired input. - public init( - url: String, - token: String, - expiresAt: Date, - refreshAfter: Date, - now: Date - ) throws { - guard Self.isCanonicalRelayURL(url) else { - throw CmxIrohRelayConfigurationError.invalidURL - } - guard (1 ... 8 * 1_024).contains(token.utf8.count), - Self.isCompactJWT(token) || Self.isLegacyRCAN(token) else { - throw CmxIrohRelayConfigurationError.invalidToken - } - guard now < refreshAfter, refreshAfter < expiresAt else { - throw CmxIrohRelayConfigurationError.invalidLifetime - } - self.url = url - self.token = token - self.expiresAt = expiresAt - self.refreshAfter = refreshAfter - } - - private static func isBase64URLByte(_ byte: UInt8) -> Bool { - (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) - || (UInt8(ascii: "A") ... UInt8(ascii: "Z")).contains(byte) - || (UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte) - || byte == UInt8(ascii: "-") - || byte == UInt8(ascii: "_") - } - - private static func isCompactJWT(_ value: String) -> Bool { - let segments = value.split(separator: ".", omittingEmptySubsequences: false) - return segments.count == 3 && segments.allSatisfy { segment in - !segment.isEmpty && segment.utf8.allSatisfy(Self.isBase64URLByte) - } - } - - private static func isLegacyRCAN(_ value: String) -> Bool { - value.utf8.allSatisfy { byte in - (UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) - || (UInt8(ascii: "2") ... UInt8(ascii: "7")).contains(byte) - } - } - - private static func isCanonicalRelayURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port == nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift deleted file mode 100644 index 8c40f7ba..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayConfigurationError.swift +++ /dev/null @@ -1,11 +0,0 @@ -/// Validation failures for a managed Iroh relay credential. -public enum CmxIrohRelayConfigurationError: Error, Equatable, Sendable { - /// The relay URL is not a canonical HTTPS origin ending in `/`. - case invalidURL - - /// The RCAN token is empty, too large, or not lowercase unpadded Base32. - case invalidToken - - /// The token expiry or refresh schedule is already invalid when decoded. - case invalidLifetime -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift deleted file mode 100644 index 1ab13321..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift +++ /dev/null @@ -1,433 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Keeps endpoint-scoped relay credentials fresh without recreating the endpoint. -public actor CmxIrohRelayCredentialCoordinator { - private static let minimumUsefulValidity: TimeInterval = 10 - private static let postExpiryRetryDelay: TimeInterval = 1 - - private struct Binding: Equatable, Sendable { - let id: String - let endpointIdentity: CmxIrohPeerIdentity - } - - private struct InstalledCredential: Equatable, Sendable { - let refreshAfter: Date - let expiresAt: Date - } - - private struct PendingPersistence: Sendable { - let response: CmxIrohRelayTokenResponse - let binding: Binding - let revision: UInt64 - } - - private struct InFlightRefresh { - let id: UUID - let task: Task<InstalledCredential, any Error> - } - - private let supervisor: CmxIrohEndpointSupervisor - private let broker: any CmxIrohRelayTokenServing - private let managedRelayURLs: Set<String> - private let selectedRelayURLs: Set<String> - private let clock: any CmxIrohRelayClock - private let jitter: @Sendable (_ now: Date, _ refreshAfter: Date) -> Date - private let retrySchedule: CmxIrohRetrySchedule - private let retryJitter: @Sendable () -> Double - private let credentialDidInstall: @Sendable (CmxIrohRelayTokenResponse) async -> Void - private var binding: Binding? - private var installedCredential: InstalledCredential? - private var lifecycleRevision: UInt64 = 0 - private var refreshTask: Task<Void, Never>? - private var inFlightRefresh: InFlightRefresh? - private var persistenceTask: Task<Void, Never>? - private var pendingPersistence: PendingPersistence? - - /// Creates an inactive relay credential coordinator. - public init( - supervisor: CmxIrohEndpointSupervisor, - broker: any CmxIrohRelayTokenServing, - managedRelayURLs: Set<String>, - selectedRelayURLs: Set<String>? = nil, - clock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - jitter: @escaping @Sendable (_ now: Date, _ refreshAfter: Date) -> Date = { - now, - refreshAfter in - let window = min(30, max(0, refreshAfter.timeIntervalSince(now))) - return refreshAfter.addingTimeInterval(-Double.random(in: 0 ... window)) - }, - retrySchedule: CmxIrohRetrySchedule = CmxIrohRetrySchedule(), - retryJitter: @escaping @Sendable () -> Double = { - Double.random(in: 0 ... 1) - }, - credentialDidInstall: @escaping @Sendable ( - CmxIrohRelayTokenResponse - ) async -> Void = { _ in } - ) { - self.supervisor = supervisor - self.broker = broker - self.managedRelayURLs = managedRelayURLs - self.selectedRelayURLs = selectedRelayURLs ?? managedRelayURLs - self.clock = clock - self.jitter = jitter - self.retrySchedule = retrySchedule - self.retryJitter = retryJitter - self.credentialDidInstall = credentialDidInstall - } - - /// Starts refresh scheduling for one exact registered endpoint binding. - /// - /// A bootstrap credential is installed before scheduling. Bootstrap - /// validation failure is returned to the caller, while an immediate broker - /// retry is still scheduled so registration remains committed and direct - /// connectivity remains available. - public func activate( - bindingID: String, - endpointIdentity: CmxIrohPeerIdentity, - bootstrap: CmxIrohRelayTokenResponse? = nil - ) async throws { - lifecycleRevision &+= 1 - let revision = lifecycleRevision - refreshTask?.cancel() - inFlightRefresh?.task.cancel() - inFlightRefresh = nil - let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity) - binding = expectedBinding - installedCredential = nil - - if let bootstrap { - do { - let installed = try await install( - bootstrap, - binding: expectedBinding, - revision: revision - ) - startLoop(revision: revision, firstRefresh: installed.refreshAfter) - return - } catch { - if isCurrent(revision), !Task.isCancelled { - startLoop(revision: revision, firstRefresh: nil) - } - throw error - } - } - do { - let response = try await broker.issueRelayToken( - bindingID: bindingID, - endpointID: endpointIdentity - ) - let installed = try await install( - response, - binding: expectedBinding, - revision: revision - ) - startLoop(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - if isCurrent(revision), !Task.isCancelled { - let delay = retryDelay(failureCount: 0, error: error) - startLoop( - revision: revision, - firstRefresh: clock.now().addingTimeInterval(delay), - initialFailureCount: 1 - ) - } - } - } - - /// Cancels all scheduled refresh work and forgets binding-scoped state. - public func deactivate() { - lifecycleRevision &+= 1 - refreshTask?.cancel() - refreshTask = nil - inFlightRefresh?.task.cancel() - inFlightRefresh = nil - persistenceTask?.cancel() - persistenceTask = nil - pendingPersistence = nil - binding = nil - installedCredential = nil - } - - /// Returns the hard expiry of the last successfully installed credential. - public func credentialExpiresAt() -> Date? { - installedCredential?.expiresAt - } - - /// Immediately catches up a missing or refresh-due relay credential. - /// - /// iOS suspends task scheduling in the background, so the ordinary sleep - /// loop may not run before an installed credential expires. Foreground - /// connection readiness calls this method before dialing. Concurrent - /// callers share one mint-and-install operation, and a failure preserves - /// the existing endpoint while resuming the bounded retry loop. - public func refreshIfNeeded() async throws { - guard let binding else { - throw CmxIrohRelayCredentialCoordinatorError.inactive - } - let now = clock.now() - if let installedCredential, - now < installedCredential.refreshAfter, - installedCredential.expiresAt.timeIntervalSince(now) - > Self.minimumUsefulValidity { - return - } - let revision = lifecycleRevision - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - refreshTask?.cancel() - startLoop(revision: revision, firstRefresh: installed.refreshAfter) - } catch { - guard isCurrent(revision), !Task.isCancelled else { - throw CancellationError() - } - refreshTask?.cancel() - let delay = retryDelay(failureCount: 0, error: error) - startLoop( - revision: revision, - firstRefresh: retryDeadline( - now: clock.now(), - backoff: delay, - honorsServerFloor: (error as? CmxIrohTrustBrokerClientError)? - .retryAfterSeconds != nil - ), - initialFailureCount: 1 - ) - throw error - } - } - - private func startLoop( - revision: UInt64, - firstRefresh: Date?, - initialFailureCount: Int = 0 - ) { - refreshTask = Task { [weak self] in - await self?.run( - revision: revision, - firstRefresh: firstRefresh, - initialFailureCount: initialFailureCount - ) - } - } - - private func run( - revision: UInt64, - firstRefresh: Date?, - initialFailureCount: Int - ) async { - var deadline = firstRefresh - var failureCount = initialFailureCount - while isCurrent(revision) { - if let deadline { - do { - try await clock.sleep(until: deadline) - } catch { - return - } - } - guard isCurrent(revision), !Task.isCancelled, let binding else { return } - do { - let installed = try await refreshCredential( - binding: binding, - revision: revision - ) - failureCount = 0 - deadline = installed.refreshAfter - } catch is CancellationError { - return - } catch { - guard isCurrent(revision), !Task.isCancelled else { return } - let now = clock.now() - let delay = retryDelay(failureCount: failureCount, error: error) - deadline = retryDeadline( - now: now, - backoff: delay, - honorsServerFloor: (error as? CmxIrohTrustBrokerClientError)? - .retryAfterSeconds != nil - ) - failureCount = min(failureCount + 1, 20) - } - } - } - - private func refreshCredential( - binding: Binding, - revision: UInt64 - ) async throws -> InstalledCredential { - if let inFlightRefresh { - return try await inFlightRefresh.task.value - } - let refreshID = UUID() - let task = Task { [weak self] in - guard let self else { throw CancellationError() } - let response = try await self.broker.issueRelayToken( - bindingID: binding.id, - endpointID: binding.endpointIdentity - ) - return try await self.install( - response, - binding: binding, - revision: revision - ) - } - inFlightRefresh = InFlightRefresh(id: refreshID, task: task) - do { - let installed = try await task.value - clearInFlightRefresh(id: refreshID) - return installed - } catch { - clearInFlightRefresh(id: refreshID) - throw error - } - } - - private func clearInFlightRefresh(id: UUID) { - guard inFlightRefresh?.id == id else { return } - inFlightRefresh = nil - } - - /// Keeps refresh retries inside the useful lifetime of an installed token. - /// - /// Exponential backoff alone can place the first retry at expiry because - /// five-minute relay tokens refresh only one minute early. Halving the - /// remaining lifetime preserves multiple bounded attempts. Once too little - /// validity remains for a useful mint-and-install round trip, retry just - /// after expiry and reset the backoff instead of growing a long outage. - private func retryDeadline( - now: Date, - backoff: TimeInterval, - honorsServerFloor: Bool - ) -> Date { - if honorsServerFloor { - return now.addingTimeInterval(backoff) - } - guard let expiresAt = installedCredential?.expiresAt, - now < expiresAt else { - return now.addingTimeInterval(backoff) - } - let remainingValidity = expiresAt.timeIntervalSince(now) - guard remainingValidity > Self.minimumUsefulValidity else { - return expiresAt.addingTimeInterval(Self.postExpiryRetryDelay) - } - return min( - now.addingTimeInterval(backoff), - now.addingTimeInterval(remainingValidity / 2) - ) - } - - private func retryDelay(failureCount: Int, error: any Error) -> TimeInterval { - retrySchedule.delay( - failureCount: failureCount, - retryAfterSeconds: (error as? CmxIrohTrustBrokerClientError)? - .retryAfterSeconds, - jitterUnitInterval: retryJitter() - ) - } - - private func install( - _ response: CmxIrohRelayTokenResponse, - binding expectedBinding: Binding, - revision: UInt64 - ) async throws -> InstalledCredential { - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - guard response.relayFleet.count == managedRelayURLs.count, - Set(response.relayFleet) == managedRelayURLs else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - let now = clock.now() - let configurations = try response.relayConfigurations(now: now) - let selectedConfigurations = configurations.filter { - selectedRelayURLs.contains($0.url) - } - guard !selectedRelayURLs.isEmpty, - selectedConfigurations.count == selectedRelayURLs.count, - selectedRelayURLs.isSubset(of: managedRelayURLs) else { - throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - } - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding else { - throw CancellationError() - } - if selectedRelayURLs == managedRelayURLs { - try await supervisor.replaceRelays( - configurations, - expectedIdentity: expectedBinding.endpointIdentity - ) - } else { - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: selectedRelayURLs, - relays: selectedConfigurations - ) - try await supervisor.replaceRelayProfile( - profile, - expectedIdentity: expectedBinding.endpointIdentity - ) - } - try Task.checkCancellation() - guard isCurrent(revision), binding == expectedBinding, - let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(), - let expiresAt = selectedConfigurations.map(\.expiresAt).min() else { - throw CancellationError() - } - let installed = InstalledCredential( - refreshAfter: scheduledRefresh(refreshAfter), - expiresAt: expiresAt - ) - installedCredential = installed - enqueuePersistence( - response: response, - binding: expectedBinding, - revision: revision - ) - return installed - } - - /// Persists only the newest installed credential on one cancellable serial lane. - /// Runtime installation and refresh scheduling never await secure storage. - private func enqueuePersistence( - response: CmxIrohRelayTokenResponse, - binding: Binding, - revision: UInt64 - ) { - pendingPersistence = PendingPersistence( - response: response, - binding: binding, - revision: revision - ) - guard persistenceTask == nil else { return } - persistenceTask = Task { [weak self] in - await self?.runPersistenceQueue() - } - } - - private func runPersistenceQueue() async { - while !Task.isCancelled, let next = pendingPersistence { - pendingPersistence = nil - guard isCurrent(next.revision), binding == next.binding else { continue } - await credentialDidInstall(next.response) - } - persistenceTask = nil - if pendingPersistence != nil, !Task.isCancelled { - persistenceTask = Task { [weak self] in - await self?.runPersistenceQueue() - } - } - } - - private func scheduledRefresh(_ refreshAfter: Date) -> Date { - let now = clock.now() - let candidate = jitter(now, refreshAfter) - return min(refreshAfter, max(now, candidate)) - } - - private func isCurrent(_ revision: UInt64) -> Bool { - lifecycleRevision == revision - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift deleted file mode 100644 index cdc52cc7..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinatorError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Relay credential scheduling failures owned by the app transport layer. -public enum CmxIrohRelayCredentialCoordinatorError: Error, Equatable, Sendable { - case inactive - case relayFleetMismatch -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift deleted file mode 100644 index f396b8bd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayDiagnosticsSnapshot.swift +++ /dev/null @@ -1,47 +0,0 @@ -public import Foundation - -/// Redacted relay policy state suitable for UI and support diagnostics. -public struct CmxIrohRelayDiagnosticsSnapshot: Equatable, Sendable { - /// Active policy source and availability. - public let source: CmxIrohRelayPolicySource - - /// Signed policy identifier, when a managed policy is active. - public let policyID: String? - - /// Signed policy sequence, when a managed policy is active. - public let policySequence: Int64? - - /// Signed policy expiry, when a managed policy is active. - public let policyExpiresAt: Date? - - /// Current account preference revision. - public let preferenceRevision: Int64? - - /// Stable relay IDs selected from the active preference. - public let selectedRelayIDs: [String] - - /// Number of relay origins currently allowed by the endpoint. - public let selectedRelayCount: Int - - /// Requested managed IDs missing from the signed policy. - public let staleRelayIDs: [String] - - /// Custom relay IDs lacking a required device-local token. - public let missingCredentialRelayIDs: [String] - - /// Last non-secret policy resolution failure. - public let failure: CmxIrohRelayPolicyFailure? - - static let inactive = CmxIrohRelayDiagnosticsSnapshot( - source: .inactive, - policyID: nil, - policySequence: nil, - policyExpiresAt: nil, - preferenceRevision: nil, - selectedRelayIDs: [], - selectedRelayCount: 0, - staleRelayIDs: [], - missingCredentialRelayIDs: [], - failure: nil - ) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift deleted file mode 100644 index 6712e257..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyCache.swift +++ /dev/null @@ -1,206 +0,0 @@ -public import Foundation - -/// Securely caches the latest root-verified relay policy with rollback protection. -public actor CmxIrohRelayPolicyCache { - private struct CachedRelay: Codable, Equatable { - let id: String - let provider: String - let region: String - let url: String - - init(_ relay: CmxIrohManagedRelayDescriptor) { - id = relay.id - provider = relay.provider - region = relay.region - url = relay.url - } - } - - private struct Record: Codable { - let version: Int - let highestSequence: Int64 - let signedPolicy: String - // Optional for records written before renewable same-catalog policies. - let catalog: [CachedRelay]? - let issuedAt: Int64? - let expiresAt: Int64? - } - - private static let storageAccount = "managed-relay-policy" - private static let recordVersion = 1 - - private let secureStore: any CmxIrohSecureCredentialStoring - private let verifier: CmxIrohRelayPolicyVerifier - private var busy = false - private var waiters: [CheckedContinuation<Void, Never>] = [] - - /// Creates an isolated relay-policy cache. - /// - /// - Parameters: - /// - secureStore: Device-local secure persistence for the signed policy record. - /// - verifier: The stateless root-pinned policy verifier. - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( - service: "com.cmuxterm.iroh.relay-policy.v1" - ), - verifier: CmxIrohRelayPolicyVerifier = CmxIrohRelayPolicyVerifier() - ) { - self.secureStore = secureStore - self.verifier = verifier - } - - /// Verifies and installs a policy unless it rolls back the stored sequence. - /// - /// - Parameters: - /// - signedPolicy: Compact JWS policy returned by the broker. - /// - trustRoot: App-pinned public verification keys. - /// - now: Verification time. - /// - Returns: The verified installed policy. - /// - Throws: ``CmxIrohRelayPolicyError`` or a secure-storage error. - public func install( - signedPolicy: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date - ) async throws -> CmxIrohManagedRelayPolicy { - await acquire() - defer { release() } - let policy = try verifier.verify(signedPolicy, trustRoot: trustRoot, now: now) - let existing = try await storedRecord() - if let existing { - guard policy.sequence > existing.highestSequence - || Self.isSafeRenewal( - policy, - signedPolicy: signedPolicy, - of: existing - ) else { - throw CmxIrohRelayPolicyError.rollback - } - } - let record = Record( - version: Self.recordVersion, - highestSequence: max(policy.sequence, existing?.highestSequence ?? 0), - signedPolicy: signedPolicy, - catalog: policy.relays.map(CachedRelay.init), - issuedAt: policy.issuedAt, - expiresAt: policy.expiresAt - ) - try await secureStore.write( - JSONEncoder().encode(record), - account: Self.storageAccount, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - return policy - } - - /// Loads and re-verifies the cached policy at the current time. - /// - /// - Parameters: - /// - trustRoot: App-pinned public verification keys. - /// - now: Verification time. - /// - Returns: The verified policy, or `nil` when no policy is cached. - /// - Throws: ``CmxIrohRelayPolicyError`` or a secure-storage error. - public func load( - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date - ) async throws -> CmxIrohManagedRelayPolicy? { - await acquire() - defer { release() } - guard let record = try await storedRecord() else { return nil } - let policy = try verifier.verify(record.signedPolicy, trustRoot: trustRoot, now: now) - guard policy.sequence == record.highestSequence, - Self.metadataMatches(policy, record: record) else { - throw CmxIrohRelayPolicyError.rollback - } - return policy - } - - /// Removes every cached relay-policy record. - public func deactivate() async throws { - await acquire() - defer { release() } - try await secureStore.deleteAll() - } - - private func acquire() async { - guard busy else { - busy = true - return - } - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - private func release() { - guard !waiters.isEmpty else { - busy = false - return - } - waiters.removeFirst().resume() - } - - private func storedRecord() async throws -> Record? { - guard let data = try await secureStore.read(account: Self.storageAccount) else { - return nil - } - guard let record = try? JSONDecoder().decode(Record.self, from: data), - record.version == Self.recordVersion, - record.highestSequence > 0, - Self.hasValidMetadataShape(record) else { - // Deleting an unreadable record would also delete the monotonic - // rollback floor. Keep it quarantined until explicit deactivation - // so an older, still-valid signed policy cannot replace it. - throw CmxIrohRelayPolicyError.invalidClaims - } - return record - } - - private static func isSafeRenewal( - _ policy: CmxIrohManagedRelayPolicy, - signedPolicy: String, - of existing: Record - ) -> Bool { - guard policy.sequence == existing.highestSequence else { return false } - guard let catalog = existing.catalog, - let issuedAt = existing.issuedAt, - let expiresAt = existing.expiresAt else { - // Preserve the previous exact-token behavior for legacy records. - return signedPolicy == existing.signedPolicy - } - return policy.relays.map(CachedRelay.init) == catalog - && policy.issuedAt >= issuedAt - && policy.expiresAt >= expiresAt - } - - private static func metadataMatches( - _ policy: CmxIrohManagedRelayPolicy, - record: Record - ) -> Bool { - guard let catalog = record.catalog, - let issuedAt = record.issuedAt, - let expiresAt = record.expiresAt else { - return true - } - return policy.relays.map(CachedRelay.init) == catalog - && policy.issuedAt == issuedAt - && policy.expiresAt == expiresAt - } - - private static func hasValidMetadataShape(_ record: Record) -> Bool { - let valuesPresent = [ - record.catalog != nil, - record.issuedAt != nil, - record.expiresAt != nil, - ] - guard valuesPresent.allSatisfy({ $0 }) || valuesPresent.allSatisfy({ !$0 }) else { - return false - } - guard let catalog = record.catalog, - let issuedAt = record.issuedAt, - let expiresAt = record.expiresAt else { return true } - return !catalog.isEmpty - && catalog.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount - && issuedAt >= 0 - && expiresAt > issuedAt - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyError.swift deleted file mode 100644 index 9014c86e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyError.swift +++ /dev/null @@ -1,35 +0,0 @@ -/// Validation failures for signed managed-relay policy and local relay selection. -public enum CmxIrohRelayPolicyError: Error, Equatable, Sendable { - /// The compact JWS does not have the required three canonical segments. - case invalidToken - - /// The JWS header is malformed or does not declare the relay-policy type. - case invalidHeader - - /// The pinned relay-policy verification keys are malformed or ambiguous. - case invalidTrustRoot - - /// The JWS key identifier is not present in the pinned trust root. - case unknownKeyID - - /// The Ed25519 signature does not authenticate the policy payload. - case invalidSignature - - /// The policy claims or relay descriptors violate the bounded schema. - case invalidClaims - - /// The policy is not valid yet at the supplied verification time. - case notYetValid - - /// The policy has reached its signed expiry. - case expired - - /// The policy requires a relay protocol this client does not implement. - case unsupportedRelayProtocol - - /// The local managed-relay selection is empty or references an unknown relay. - case invalidSelection - - /// A valid policy is older than the highest policy sequence already installed. - case rollback -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift deleted file mode 100644 index c73aa1da..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyFailure.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// Non-secret reason a requested relay preference could not become effective. -public enum CmxIrohRelayPolicyFailure: String, Codable, Equatable, Sendable { - /// No signed managed policy is available. - case policyUnavailable - - /// The cached or broker policy has expired. - case policyExpired - - /// The broker policy failed signature or schema validation. - case policyRejected - - /// The broker policy attempted a sequence rollback or equivocation. - case policyRollback - - /// Every requested managed relay identifier disappeared from the policy. - case staleManagedSelection - - /// At least one custom relay requires a token that is absent on this device. - case missingCustomCredential - - /// Secure storage for custom relay tokens could not be read. - case customCredentialUnavailable - - /// The broker attempted a preference revision rollback or equivocation. - case preferenceRollback - - /// The server committed an account change that this device could not cache. - case preferencePersistenceUnavailable - - /// The signed managed allowlist is active without a usable current token. - case managedCredentialUnavailable -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift deleted file mode 100644 index 662489f4..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResolution.swift +++ /dev/null @@ -1,324 +0,0 @@ -import CMUXMobileCore -import Foundation - -struct CmxIrohRelayPolicyResolutionResult { - let effective: CmxIrohEffectiveRelayPolicy - let failure: CmxIrohRelayPolicyFailure? -} - -enum CmxIrohRelayPolicyResolution { - typealias Resolution = CmxIrohRelayPolicyResolutionResult - - static func resolve( - configuration: CmxIrohAccountRelayConfiguration, - revision: Int64, - policy: CmxIrohManagedRelayPolicy?, - relayCredential: CmxIrohRelayTokenResponse?, - accountID: String, - credentialStore: CmxIrohCustomRelayCredentialStore, - usedCachedPolicy: Bool, - now: Date - ) async -> Resolution { - let preference = configuration.activePreference - switch preference { - case .automatic: - guard let policy else { - return unavailableResolution( - configuration: configuration, - revision: revision, - source: .managedUnavailable, - failure: .policyUnavailable - ) - } - return resolveManaged( - selection: .automatic, - requestedConfiguration: configuration, - effectivePreference: .automatic, - policy: policy, - credential: relayCredential, - staleRelayIDs: [], - revision: revision, - usedCachedPolicy: usedCachedPolicy, - now: now - ) - case let .managed(requestedIDs): - guard let policy else { - return unavailableResolution( - configuration: configuration, - revision: revision, - source: .managedUnavailable, - failure: .policyUnavailable - ) - } - let policyIDs = Set(policy.relays.map(\.id)) - let surviving = requestedIDs.intersection(policyIDs) - let stale = requestedIDs.subtracting(policyIDs) - guard !surviving.isEmpty else { - return unavailableResolution( - configuration: configuration, - revision: revision, - source: .managedUnavailable, - staleRelayIDs: stale, - policy: policy, - usedCachedPolicy: usedCachedPolicy, - failure: .staleManagedSelection - ) - } - return resolveManaged( - selection: .only(surviving), - requestedConfiguration: configuration, - effectivePreference: .managed(surviving), - policy: policy, - credential: relayCredential, - staleRelayIDs: stale, - revision: revision, - usedCachedPolicy: usedCachedPolicy, - now: now - ) - case let .custom(definitions): - let tokens: [String: String] - let authenticatedDefinitions = definitions.filter { $0.authMode == .staticToken } - if authenticatedDefinitions.isEmpty { - tokens = [:] - } else { - do { - tokens = try await credentialStore.staticTokens( - for: authenticatedDefinitions, - accountID: accountID - ) - } catch { - return unavailableResolution( - configuration: configuration, - revision: revision, - source: .customUnavailable, - policy: policy, - usedCachedPolicy: usedCachedPolicy, - failure: .customCredentialUnavailable - ) - } - } - let missing = Set(definitions.compactMap { definition in - definition.authMode == .staticToken && tokens[definition.id] == nil - ? definition.id - : nil - }) - guard missing.isEmpty else { - return unavailableResolution( - configuration: configuration, - revision: revision, - source: .customUnavailable, - missingCredentialRelayIDs: missing, - policy: policy, - usedCachedPolicy: usedCachedPolicy, - failure: .missingCustomCredential - ) - } - do { - let relays = try definitions.map { definition in - try CmxIrohCustomRelay( - url: definition.url, - authenticationToken: definition.authMode == .staticToken - ? tokens[definition.id] - : nil - ) - } - let custom = try CmxIrohCustomRelayProfile(relays: relays) - return Resolution( - effective: CmxIrohEffectiveRelayPolicy( - endpointRelayProfile: CmxIrohEndpointRelayProfile(customProfile: custom), - managedSnapshot: nil, - managedPolicy: policy, - requestedConfiguration: configuration, - effectivePreference: preference, - source: .custom, - usedCachedPolicy: usedCachedPolicy, - preferenceRevision: revision - ), - failure: nil - ) - } catch { - return unavailableResolution( - configuration: configuration, - revision: revision, - source: .customUnavailable, - policy: policy, - usedCachedPolicy: usedCachedPolicy, - failure: .policyRejected - ) - } - } - } - - private static func resolveManaged( - selection: CmxIrohManagedRelaySelection, - requestedConfiguration: CmxIrohAccountRelayConfiguration, - effectivePreference: CmxIrohAccountRelayPreference, - policy: CmxIrohManagedRelayPolicy, - credential: CmxIrohRelayTokenResponse?, - staleRelayIDs: Set<String>, - revision: Int64, - usedCachedPolicy: Bool, - now: Date - ) -> Resolution { - do { - let snapshot = try CmxIrohRelayPolicySnapshot(policy: policy, selection: selection) - var selectedCredentials: [CmxIrohRelayConfiguration] = [] - var failure: CmxIrohRelayPolicyFailure? - var relayBootstrap: CmxIrohRelayTokenResponse? - if let credential, - Set(credential.relayFleet) == Set(policy.relays.map(\.url)), - credential.relayFleet.count == policy.relays.count, - let configurations = try? credential.relayConfigurations(now: now) { - selectedCredentials = configurations.filter { snapshot.relayURLs.contains($0.url) } - relayBootstrap = credential - } else { - failure = .managedCredentialUnavailable - } - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: snapshot.relayURLs, - relays: selectedCredentials - ) - return Resolution( - effective: CmxIrohEffectiveRelayPolicy( - endpointRelayProfile: profile, - managedSnapshot: snapshot, - managedPolicy: policy, - requestedConfiguration: requestedConfiguration, - effectivePreference: effectivePreference, - staleRelayIDs: staleRelayIDs, - source: .managed, - usedCachedPolicy: usedCachedPolicy, - preferenceRevision: revision, - relayBootstrap: relayBootstrap - ), - failure: failure - ) - } catch { - return unavailableResolution( - configuration: requestedConfiguration, - revision: revision, - source: .managedUnavailable, - staleRelayIDs: staleRelayIDs, - policy: policy, - usedCachedPolicy: usedCachedPolicy, - failure: .policyRejected - ) - } - } - - static func unavailableResolution( - configuration: CmxIrohAccountRelayConfiguration?, - revision: Int64?, - source: CmxIrohRelayPolicySource, - staleRelayIDs: Set<String> = [], - missingCredentialRelayIDs: Set<String> = [], - policy: CmxIrohManagedRelayPolicy? = nil, - usedCachedPolicy: Bool = false, - failure: CmxIrohRelayPolicyFailure - ) -> Resolution { - Resolution( - effective: CmxIrohEffectiveRelayPolicy( - endpointRelayProfile: source == .customUnavailable - ? .unavailableCustomOverride - : .unavailableManagedSelection, - managedSnapshot: nil, - managedPolicy: policy, - requestedConfiguration: configuration, - effectivePreference: nil, - staleRelayIDs: staleRelayIDs, - missingCredentialRelayIDs: missingCredentialRelayIDs, - source: source, - usedCachedPolicy: usedCachedPolicy, - preferenceRevision: revision - ), - failure: failure - ) - } - - static func validatePreferenceRevision( - _ revision: Int64, - configuration: CmxIrohAccountRelayConfiguration, - accountID: String, - currentEffective: CmxIrohEffectiveRelayPolicy?, - preferenceStore: CmxIrohRelayPreferenceStore - ) async throws { - let currentRevision = currentEffective?.preferenceRevision - let currentConfiguration = currentEffective?.requestedConfiguration - if let currentRevision, let currentConfiguration { - guard revision > currentRevision - || (revision == currentRevision && configuration == currentConfiguration) else { - throw CmxIrohRelayPolicyServiceError.preferenceRollback - } - return - } - guard let existing = try await preferenceStore.load(accountID: accountID) else { return } - guard revision > existing.revision - || (revision == existing.revision && configuration == existing.requested) else { - throw CmxIrohRelayPolicyServiceError.preferenceRollback - } - } - - static func cleanupOrphanCredentials( - configuration: CmxIrohAccountRelayConfiguration, - accountID: String, - credentialStore: CmxIrohCustomRelayCredentialStore - ) async -> CmxIrohRelayPolicyFailure? { - do { - try await credentialStore.retainCredentials( - for: configuration.customRelays, - accountID: accountID - ) - return nil - } catch { - return .customCredentialUnavailable - } - } - - static func diagnostics( - for effective: CmxIrohEffectiveRelayPolicy, - failure: CmxIrohRelayPolicyFailure? - ) -> CmxIrohRelayDiagnosticsSnapshot { - let policy = effective.managedPolicy - let selectedIDs: [String] - switch effective.effectivePreference { - case let .managed(ids): - selectedIDs = ids.sorted() - case let .custom(relays): - selectedIDs = relays.map(\.id).sorted() - case .automatic: - selectedIDs = effective.managedSnapshot?.relays.map(\.id).sorted() ?? [] - case nil: - selectedIDs = [] - } - return CmxIrohRelayDiagnosticsSnapshot( - source: effective.source, - policyID: policy?.policyID, - policySequence: policy?.sequence, - policyExpiresAt: policy.map { Date(timeIntervalSince1970: TimeInterval($0.expiresAt)) }, - preferenceRevision: effective.preferenceRevision, - selectedRelayIDs: selectedIDs, - selectedRelayCount: effective.endpointRelayProfile.allowedRelayURLs.count, - staleRelayIDs: effective.staleRelayIDs.sorted(), - missingCredentialRelayIDs: effective.missingCredentialRelayIDs.sorted(), - failure: failure - ) - } - - static func failure(for error: any Error) -> CmxIrohRelayPolicyFailure { - if let serviceError = error as? CmxIrohRelayPolicyServiceError, - serviceError == .preferenceRollback { - return .preferenceRollback - } - guard let policyError = error as? CmxIrohRelayPolicyError else { - return .policyRejected - } - switch policyError { - case .expired: - return .policyExpired - case .rollback: - return .policyRollback - default: - return .policyRejected - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResponse.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResponse.swift deleted file mode 100644 index 4f2f9bab..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyResponse.swift +++ /dev/null @@ -1,43 +0,0 @@ -/// Signed managed policy and the account preference revision resolved with it. -public struct CmxIrohRelayPolicyResponse: Codable, Equatable, Sendable { - /// Compact Ed25519-signed managed relay policy. - public let policy: String - - /// Complete current account configuration. - public let preference: CmxIrohAccountRelayConfiguration - - /// Monotonic account preference revision. - public let preferenceRevision: Int64 - - /// Creates a validated relay policy response. - public init( - policy: String, - preference: CmxIrohAccountRelayConfiguration, - preferenceRevision: Int64 - ) throws { - guard (1 ... 64 * 1_024).contains(policy.utf8.count), - policy.split(separator: ".", omittingEmptySubsequences: false).count == 3, - preferenceRevision >= 0 else { - throw CmxIrohRelayPolicyError.invalidClaims - } - self.policy = policy - self.preference = preference - self.preferenceRevision = preferenceRevision - } - - /// Decodes and revalidates one broker response. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - do { - try self.init( - policy: container.decode(String.self, forKey: .policy), - preference: container.decode(CmxIrohAccountRelayConfiguration.self, forKey: .preference), - preferenceRevision: container.decode(Int64.self, forKey: .preferenceRevision) - ) - } catch { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid relay policy response") - ) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift deleted file mode 100644 index c1335bf7..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +++ /dev/null @@ -1,498 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Resolves signed managed policy, account preference, and device-only credentials. -public actor CmxIrohRelayPolicyService { - private typealias Resolution = CmxIrohRelayPolicyResolutionResult - private typealias Resolver = CmxIrohRelayPolicyResolution - - private let policyCache: CmxIrohRelayPolicyCache - private let preferenceStore: CmxIrohRelayPreferenceStore - private let credentialStore: CmxIrohCustomRelayCredentialStore - private let broker: (any CmxIrohRelayPolicyServing)? - private var currentEffective: CmxIrohEffectiveRelayPolicy? - private var currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot.inactive - private var continuations: [UUID: AsyncStream<CmxIrohRelayDiagnosticsSnapshot>.Continuation] = [:] - private var operationRevision: UInt64 = 0 - - /// Creates an inactive relay policy service with injected persistence boundaries. - public init( - policyCache: CmxIrohRelayPolicyCache = CmxIrohRelayPolicyCache(), - preferenceStore: CmxIrohRelayPreferenceStore = CmxIrohRelayPreferenceStore(), - credentialStore: CmxIrohCustomRelayCredentialStore = CmxIrohCustomRelayCredentialStore(), - broker: (any CmxIrohRelayPolicyServing)? = nil - ) { - self.policyCache = policyCache - self.preferenceStore = preferenceStore - self.credentialStore = credentialStore - self.broker = broker - } - - /// Fetches and installs the broker's current relay bootstrap response. - @discardableResult - public func refresh( - endpointID: CmxIrohPeerIdentity, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date = Date() - ) async throws -> CmxIrohEffectiveRelayPolicy { - guard let broker else { throw CmxIrohRelayPolicyServiceError.brokerUnavailable } - let bootstrap = try await broker.issueRelayBootstrap(endpointID: endpointID) - return try await install( - response: bootstrap.relayPolicy, - accountID: accountID, - trustRoot: trustRoot, - relayCredential: bootstrap.relayToken, - now: now - ) - } - - /// Verifies and resolves one broker response without replacing last-known-good - /// runtime state when signature, expiry, rollback, or persistence checks fail. - @discardableResult - public func install( - response: CmxIrohRelayPolicyResponse, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, - now: Date = Date() - ) async throws -> CmxIrohEffectiveRelayPolicy { - let operation = beginOperation() - do { - try await Resolver.validatePreferenceRevision( - response.preferenceRevision, - configuration: response.preference, - accountID: accountID, - currentEffective: currentEffective, - preferenceStore: preferenceStore - ) - let policy = try await policyCache.install( - signedPolicy: response.policy, - trustRoot: trustRoot, - now: now - ) - let resolution = await Resolver.resolve( - configuration: response.preference, - revision: response.preferenceRevision, - policy: policy, - relayCredential: relayCredential, - accountID: accountID, - credentialStore: credentialStore, - usedCachedPolicy: false, - now: now - ) - _ = try await preferenceStore.install( - requested: response.preference, - effective: resolution.effective.effectivePreference, - revision: response.preferenceRevision, - effectivePolicySequence: resolution.effective.managedPolicy?.sequence, - staleRelayIDs: resolution.effective.staleRelayIDs, - accountID: accountID - ) - let cleanupFailure = await Resolver.cleanupOrphanCredentials( - configuration: response.preference, - accountID: accountID, - credentialStore: credentialStore - ) - try requireCurrent(operation) - publish( - resolution.effective, - failure: cleanupFailure ?? resolution.failure - ) - return resolution.effective - } catch { - if isCurrent(operation) { - publishFailure(Resolver.failure(for: error)) - } - throw error - } - } - - /// Restores the last-known-good signed policy and account preference. - @discardableResult - public func restore( - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, - now: Date = Date() - ) async -> CmxIrohEffectiveRelayPolicy { - let operation = beginOperation() - let persisted: CmxIrohPersistedRelayPreference - do { - guard let stored = try await preferenceStore.load(accountID: accountID) else { - return publishUnavailable( - configuration: nil, - revision: nil, - source: .managedUnavailable, - operation: operation, - failure: .policyUnavailable - ) - } - persisted = stored - } catch { - return publishUnavailable( - configuration: nil, - revision: nil, - source: .managedUnavailable, - operation: operation, - failure: .policyUnavailable - ) - } - - let cleanupFailure = await Resolver.cleanupOrphanCredentials( - configuration: persisted.requested, - accountID: accountID, - credentialStore: credentialStore - ) - if persisted.requested.mode == .custom { - let policy = try? await policyCache.load(trustRoot: trustRoot, now: now) - let resolution = await Resolver.resolve( - configuration: persisted.requested, - revision: persisted.revision, - policy: policy, - relayCredential: nil, - accountID: accountID, - credentialStore: credentialStore, - usedCachedPolicy: policy != nil, - now: now - ) - return commit( - Resolution( - effective: resolution.effective, - failure: cleanupFailure ?? resolution.failure - ), - operation: operation - ) - } - - do { - guard let policy = try await policyCache.load(trustRoot: trustRoot, now: now) else { - return publishUnavailable( - configuration: persisted.requested, - revision: persisted.revision, - source: .managedUnavailable, - operation: operation, - failure: .policyUnavailable - ) - } - let resolution = await Resolver.resolve( - configuration: persisted.requested, - revision: persisted.revision, - policy: policy, - relayCredential: relayCredential, - accountID: accountID, - credentialStore: credentialStore, - usedCachedPolicy: true, - now: now - ) - return commit( - Resolution( - effective: resolution.effective, - failure: cleanupFailure ?? resolution.failure - ), - operation: operation - ) - } catch let error as CmxIrohRelayPolicyError where error == .expired { - return publishUnavailable( - configuration: persisted.requested, - revision: persisted.revision, - source: .managedUnavailable, - operation: operation, - failure: .policyExpired - ) - } catch { - return publishUnavailable( - configuration: persisted.requested, - revision: persisted.revision, - source: .managedUnavailable, - operation: operation, - failure: Resolver.failure(for: error) - ) - } - } - - /// Updates only the active preference while retaining dormant account fields. - @discardableResult - public func setPreference( - _ preference: CmxIrohAccountRelayPreference, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, - now: Date = Date() - ) async throws -> CmxIrohEffectiveRelayPolicy { - let current: CmxIrohAccountRelayConfiguration - if let live = currentEffective?.requestedConfiguration { - current = live - } else { - current = try await preferenceStore.load(accountID: accountID)?.requested - ?? .automatic - } - return try await setConfiguration( - current.updatingActivePreference(preference), - accountID: accountID, - trustRoot: trustRoot, - relayCredential: relayCredential, - now: now - ) - } - - /// Replaces the authoritative account configuration using optimistic concurrency. - /// Once the broker commits, local cache or Keychain failures are represented in - /// diagnostics while the returned state still reflects the committed account. - @discardableResult - public func setConfiguration( - _ configuration: CmxIrohAccountRelayConfiguration, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse? = nil, - now: Date = Date() - ) async throws -> CmxIrohEffectiveRelayPolicy { - let operation = beginOperation() - guard let broker else { throw CmxIrohRelayPolicyServiceError.brokerUnavailable } - _ = try JSONEncoder().encode(configuration) - let expectedRevision: Int64? - if let liveRevision = currentEffective?.preferenceRevision { - expectedRevision = liveRevision - } else { - expectedRevision = try await preferenceStore.load(accountID: accountID)?.revision - } - let request = try CmxIrohRelayPreferenceUpdateRequest( - expectedRevision: expectedRevision, - preference: configuration - ) - let response: CmxIrohRelayPreferenceResponse - do { - response = try await broker.updateRelayPreference(request) - } catch { - if let authoritative = try? await broker.relayPreference() { - _ = try? await reconcileCommittedConfiguration( - authoritative, - accountID: accountID, - trustRoot: trustRoot, - relayCredential: relayCredential, - now: now, - operation: operation - ) - } - throw error - } - return try await reconcileCommittedConfiguration( - response, - accountID: accountID, - trustRoot: trustRoot, - relayCredential: relayCredential, - now: now, - operation: operation - ) - } - - /// Returns the last authoritative account configuration known in memory. - public func accountConfiguration() -> CmxIrohAccountRelayConfiguration? { - currentEffective?.requestedConfiguration - } - - /// Returns only relay identifiers with configured device-local credentials. - /// A `nil` result means secure storage could not be read. - public func configuredCustomCredentialRelayIDs( - accountID: String - ) async -> Set<String>? { - do { - return try await credentialStore.configuredRelayIDs(accountID: accountID) - } catch { - return nil - } - } - private func reconcileCommittedConfiguration( - _ response: CmxIrohRelayPreferenceResponse, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - relayCredential: CmxIrohRelayTokenResponse?, - now: Date, - operation: UInt64 - ) async throws -> CmxIrohEffectiveRelayPolicy { - try await Resolver.validatePreferenceRevision( - response.revision, - configuration: response.preference, - accountID: accountID, - currentEffective: currentEffective, - preferenceStore: preferenceStore - ) - let policy = try? await policyCache.load(trustRoot: trustRoot, now: now) - let resolution = await Resolver.resolve( - configuration: response.preference, - revision: response.revision, - policy: policy, - relayCredential: relayCredential, - accountID: accountID, - credentialStore: credentialStore, - usedCachedPolicy: policy != nil, - now: now - ) - var failure = resolution.failure - do { - _ = try await preferenceStore.install( - requested: response.preference, - effective: resolution.effective.effectivePreference, - revision: response.revision, - effectivePolicySequence: resolution.effective.managedPolicy?.sequence, - staleRelayIDs: resolution.effective.staleRelayIDs, - accountID: accountID - ) - } catch { - failure = .preferencePersistenceUnavailable - } - if let cleanupFailure = await Resolver.cleanupOrphanCredentials( - configuration: response.preference, - accountID: accountID, - credentialStore: credentialStore - ) { - failure = cleanupFailure - } - guard isCurrent(operation) else { - return currentEffective ?? resolution.effective - } - publish(resolution.effective, failure: failure) - return resolution.effective - } - - /// Saves a device-local custom token and re-resolves the current preference. - @discardableResult - public func setStaticCredential( - _ token: String, - relayID: String, - relayURL: String, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date = Date() - ) async throws -> CmxIrohEffectiveRelayPolicy { - try await credentialStore.setStaticToken( - token, - relayID: relayID, - relayURL: relayURL, - accountID: accountID - ) - return await restore(accountID: accountID, trustRoot: trustRoot, now: now) - } - - /// Removes a device-local custom token and immediately fails closed if required. - @discardableResult - public func removeStaticCredential( - relayID: String, - accountID: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date = Date() - ) async throws -> CmxIrohEffectiveRelayPolicy { - try await credentialStore.removeCredential(relayID: relayID, accountID: accountID) - return await restore(accountID: accountID, trustRoot: trustRoot, now: now) - } - - /// Returns the most recently resolved effective policy. - public func effectivePolicy() -> CmxIrohEffectiveRelayPolicy? { - currentEffective - } - - /// Returns the latest root-verified managed catalog, even during custom mode. - public func managedPolicy() -> CmxIrohManagedRelayPolicy? { - currentEffective?.managedPolicy - } - - /// Returns the latest redacted diagnostics snapshot. - public func diagnosticsSnapshot() -> CmxIrohRelayDiagnosticsSnapshot { - currentDiagnostics - } - - /// Observes redacted diagnostics changes, beginning with the current snapshot. - public func diagnosticsSnapshots() -> AsyncStream<CmxIrohRelayDiagnosticsSnapshot> { - let id = UUID() - return AsyncStream { continuation in - continuations[id] = continuation - continuation.yield(currentDiagnostics) - continuation.onTermination = { [weak self] _ in - Task { await self?.removeContinuation(id) } - } - } - } - private func publishUnavailable( - configuration: CmxIrohAccountRelayConfiguration?, - revision: Int64?, - source: CmxIrohRelayPolicySource, - operation: UInt64, - failure: CmxIrohRelayPolicyFailure - ) -> CmxIrohEffectiveRelayPolicy { - let resolution = Resolver.unavailableResolution( - configuration: configuration, - revision: revision, - source: source, - failure: failure - ) - return commit(resolution, operation: operation) - } - - private func commit( - _ resolution: Resolution, - operation: UInt64 - ) -> CmxIrohEffectiveRelayPolicy { - guard isCurrent(operation) else { - return currentEffective ?? resolution.effective - } - publish(resolution.effective, failure: resolution.failure) - return resolution.effective - } - - private func beginOperation() -> UInt64 { - operationRevision &+= 1 - return operationRevision - } - - private func requireCurrent(_ operation: UInt64) throws { - guard isCurrent(operation) else { - throw CmxIrohRelayPolicyServiceError.superseded - } - } - - private func isCurrent(_ operation: UInt64) -> Bool { - operationRevision == operation - } - - - private func publish( - _ effective: CmxIrohEffectiveRelayPolicy, - failure: CmxIrohRelayPolicyFailure? - ) { - currentEffective = effective - currentDiagnostics = Resolver.diagnostics(for: effective, failure: failure) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } - } - - private func publishFailure(_ failure: CmxIrohRelayPolicyFailure) { - guard let effective = currentEffective else { - currentDiagnostics = CmxIrohRelayDiagnosticsSnapshot( - source: .inactive, - policyID: nil, - policySequence: nil, - policyExpiresAt: nil, - preferenceRevision: nil, - selectedRelayIDs: [], - selectedRelayCount: 0, - staleRelayIDs: [], - missingCredentialRelayIDs: [], - failure: failure - ) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } - return - } - currentDiagnostics = Resolver.diagnostics(for: effective, failure: failure) - for continuation in continuations.values { - continuation.yield(currentDiagnostics) - } - } - - private func removeContinuation(_ id: UUID) { - continuations.removeValue(forKey: id) - } - -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift deleted file mode 100644 index 0474fb51..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServiceError.swift +++ /dev/null @@ -1,14 +0,0 @@ -/// Failures specific to relay policy orchestration. -public enum CmxIrohRelayPolicyServiceError: Error, Equatable, Sendable { - /// No broker was injected for a network-backed operation. - case brokerUnavailable - - /// A managed bootstrap omitted its endpoint-scoped relay credential. - case managedCredentialUnavailable - - /// A preference revision rolled back or equivocated. - case preferenceRollback - - /// A newer policy operation superseded this suspended operation. - case superseded -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift deleted file mode 100644 index 6387636c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyServing.swift +++ /dev/null @@ -1,17 +0,0 @@ -public import CMUXMobileCore - -/// Authenticated broker operations used by the relay policy service. -public protocol CmxIrohRelayPolicyServing: Sendable { - /// Issues endpoint-scoped relay bootstrap material. - func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse - - /// Fetches the current account relay preference. - func relayPreference() async throws -> CmxIrohRelayPreferenceResponse - - /// Replaces the account relay preference using optimistic concurrency. - func updateRelayPreference( - _ request: CmxIrohRelayPreferenceUpdateRequest - ) async throws -> CmxIrohRelayPreferenceResponse -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicySnapshot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicySnapshot.swift deleted file mode 100644 index aa7f0536..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicySnapshot.swift +++ /dev/null @@ -1,31 +0,0 @@ -/// One verified managed policy with its resolved device-local relay selection. -public struct CmxIrohRelayPolicySnapshot: Equatable, Sendable { - /// The signed policy that authorizes every selected relay URL. - public let policy: CmxIrohManagedRelayPolicy - - /// The device-local selection used to derive ``relays``. - public let selection: CmxIrohManagedRelaySelection - - /// The selected relays in signed policy order. - public let relays: [CmxIrohManagedRelayDescriptor] - - /// Exact selected relay origins accepted by runtime and cache validation. - public var relayURLs: Set<String> { - Set(relays.map(\.url)) - } - - /// Resolves a local selection against a verified policy. - /// - /// - Parameters: - /// - policy: A policy returned by ``CmxIrohRelayPolicyVerifier``. - /// - selection: The device-local managed-relay selection. - /// - Throws: ``CmxIrohRelayPolicyError/invalidSelection`` for stale selection. - public init( - policy: CmxIrohManagedRelayPolicy, - selection: CmxIrohManagedRelaySelection - ) throws { - self.policy = policy - self.selection = selection - relays = try selection.resolve(in: policy) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicySource.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicySource.swift deleted file mode 100644 index 27c3f840..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicySource.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Origin and availability of the currently effective relay profile. -public enum CmxIrohRelayPolicySource: String, Codable, Equatable, Sendable { - /// No account policy has been restored. - case inactive - - /// A verified broker-managed relay profile is active. - case managed - - /// A complete user-defined relay profile is active. - case custom - - /// The requested managed selection cannot be honored. - case managedUnavailable - - /// The requested custom selection cannot be honored. - case customUnavailable -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift deleted file mode 100644 index 454057ef..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyTrustRoot.swift +++ /dev/null @@ -1,54 +0,0 @@ -/// Immutable public keys pinned by the app for relay-policy verification. -public struct CmxIrohRelayPolicyTrustRoot: Equatable, Sendable { - /// The current and staged-next keys accepted during rotation. - public let keys: [CmxIrohRelayPolicyVerificationKey] - - /// Creates a bounded relay-policy trust root. - /// - /// A release may pin a current key and staged replacements. Routine policy - /// changes therefore do not pin relay URLs or require an app update. - /// - /// - Parameter keys: Between one and four unique Ed25519 verification keys. - /// - Throws: ``CmxIrohRelayPolicyError/invalidTrustRoot`` for an invalid set. - public init(keys: [CmxIrohRelayPolicyVerificationKey]) throws { - guard (1 ... 4).contains(keys.count), - Set(keys.map(\.keyID)).count == keys.count else { - throw CmxIrohRelayPolicyError.invalidTrustRoot - } - self.keys = keys - } - - /// Reads the current and staged-next public keys from an app information dictionary. - /// - /// The array form is authoritative so a release can overlap signing keys during - /// rotation. The single-key form remains supported for already-shipped builds. - public static func appPinned( - infoDictionary: [String: Any]? - ) -> CmxIrohRelayPolicyTrustRoot? { - let records: [[String: String]] - if let configured = infoDictionary?["CMUXIrohRelayPolicyTrustKeys"] - as? [[String: String]] { - records = configured - } else if let keyID = infoDictionary?["CMUXIrohRelayPolicyKeyID"] as? String, - let publicKey = infoDictionary?["CMUXIrohRelayPolicyPublicKeyBase64"] - as? String { - records = [["keyID": keyID, "publicKeyBase64": publicKey]] - } else { - return nil - } - let keys = records.compactMap { record -> CmxIrohRelayPolicyVerificationKey? in - guard let keyID = record["keyID"], - let publicKey = record["publicKeyBase64"] else { return nil } - return try? CmxIrohRelayPolicyVerificationKey( - keyID: keyID, - rawPublicKeyBase64: publicKey - ) - } - guard keys.count == records.count else { return nil } - return try? CmxIrohRelayPolicyTrustRoot(keys: keys) - } - - func key(id: String) -> CmxIrohRelayPolicyVerificationKey? { - keys.first { $0.keyID == id } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyVerificationKey.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyVerificationKey.swift deleted file mode 100644 index 16e7fa65..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyVerificationKey.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// One pinned Ed25519 public key accepted for managed-relay policy signatures. -public struct CmxIrohRelayPolicyVerificationKey: Equatable, Sendable { - /// The bounded JWS key identifier. - public let keyID: String - - /// The canonical standard-Base64 encoding of the 32-byte Ed25519 public key. - public let rawPublicKeyBase64: String - - /// Creates one pinned relay-policy verification key. - /// - /// - Parameters: - /// - keyID: The `kid` accepted in a relay-policy JWS header. - /// - rawPublicKeyBase64: A canonical Base64-encoded Ed25519 public key. - /// - Throws: ``CmxIrohRelayPolicyError/invalidTrustRoot`` for malformed input. - public init(keyID: String, rawPublicKeyBase64: String) throws { - guard Self.isSafeKeyID(keyID), - let key = Data(base64Encoded: rawPublicKeyBase64), - key.count == 32, - key.base64EncodedString() == rawPublicKeyBase64 else { - throw CmxIrohRelayPolicyError.invalidTrustRoot - } - self.keyID = keyID - self.rawPublicKeyBase64 = rawPublicKeyBase64 - } - - var rawPublicKey: Data { - Data(base64Encoded: rawPublicKeyBase64)! - } - - static func isSafeKeyID(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyVerifier.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyVerifier.swift deleted file mode 100644 index 8deeabce..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyVerifier.swift +++ /dev/null @@ -1,276 +0,0 @@ -import CryptoKit -public import Foundation - -/// Verifies root-pinned compact JWS relay policies with strict claim shape. -public struct CmxIrohRelayPolicyVerifier: Sendable { - /// Maximum relays accepted in one catalog, including all managed providers. - public static let maximumRelayCount = 16 - - private struct Header: Decodable { - let alg: String - let typ: String - let kid: String - } - - private struct RelayClaims: Decodable { - let id: String - let provider: String - let region: String - let url: String - } - - private struct PolicyClaims: Decodable { - let version: Int - let policyID: String - let sequence: Int64 - let issuedAt: Int64 - let notBefore: Int64 - let expiresAt: Int64 - let audience: String - let relayProtocol: String - let relays: [RelayClaims] - - private enum CodingKeys: String, CodingKey { - case version - case policyID = "jti" - case sequence - case issuedAt = "iat" - case notBefore = "nbf" - case expiresAt = "exp" - case audience = "aud" - case relayProtocol = "relay_protocol" - case relays - } - } - - private static let tokenType = "cmux-relay-policy-v1+jwt" - private static let audience = "cmux-iroh-relay-policy" - private static let relayProtocol = "iroh-relay-v1" - private static let maximumLifetime: Int64 = 7 * 24 * 60 * 60 - - /// Creates a stateless relay-policy verifier. - public init() {} - - /// Authenticates and validates one signed relay policy. - /// - /// - Parameters: - /// - token: A compact EdDSA JWS issued by cmux's relay-policy authority. - /// - trustRoot: App-pinned public keys, never supplied by the policy response. - /// - now: Verification time, injected for deterministic tests. - /// - Returns: A verified managed-relay policy. - /// - Throws: ``CmxIrohRelayPolicyError`` for signature, shape, or time failures. - public func verify( - _ token: String, - trustRoot: CmxIrohRelayPolicyTrustRoot, - now: Date - ) throws -> CmxIrohManagedRelayPolicy { - guard (5 ... 64 * 1_024).contains(token.utf8.count) else { - throw CmxIrohRelayPolicyError.invalidToken - } - let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count == 3, - let headerData = Self.decodeBase64URL(String(segments[0])), - let payload = Self.decodeBase64URL(String(segments[1])), - let signature = Self.decodeBase64URL(String(segments[2])), - signature.count == 64 else { - throw CmxIrohRelayPolicyError.invalidToken - } - try Self.requireExactKeys(headerData, expected: ["alg", "typ", "kid"]) - let header: Header - do { - header = try JSONDecoder().decode(Header.self, from: headerData) - } catch { - throw CmxIrohRelayPolicyError.invalidHeader - } - guard header.alg == "EdDSA", - header.typ == Self.tokenType, - CmxIrohRelayPolicyVerificationKey.isSafeKeyID(header.kid) else { - throw CmxIrohRelayPolicyError.invalidHeader - } - guard let verificationKey = trustRoot.key(id: header.kid) else { - throw CmxIrohRelayPolicyError.unknownKeyID - } - let publicKey: Curve25519.Signing.PublicKey - do { - publicKey = try Curve25519.Signing.PublicKey( - rawRepresentation: verificationKey.rawPublicKey - ) - } catch { - throw CmxIrohRelayPolicyError.invalidTrustRoot - } - let signingInput = Data("\(segments[0]).\(segments[1])".utf8) - guard publicKey.isValidSignature(signature, for: signingInput) else { - throw CmxIrohRelayPolicyError.invalidSignature - } - - try Self.requireExactPolicyShape(payload) - let claims: PolicyClaims - do { - claims = try JSONDecoder().decode(PolicyClaims.self, from: payload) - } catch { - throw CmxIrohRelayPolicyError.invalidClaims - } - let policy = CmxIrohManagedRelayPolicy( - version: claims.version, - policyID: claims.policyID, - sequence: claims.sequence, - issuedAt: claims.issuedAt, - notBefore: claims.notBefore, - expiresAt: claims.expiresAt, - audience: claims.audience, - relayProtocol: claims.relayProtocol, - relays: claims.relays.map { - CmxIrohManagedRelayDescriptor( - id: $0.id, - provider: $0.provider, - region: $0.region, - url: $0.url - ) - } - ) - try Self.validate(policy, now: now) - return policy - } - - private static func validate( - _ policy: CmxIrohManagedRelayPolicy, - now: Date - ) throws { - let time = now.timeIntervalSince1970 - guard time.isFinite, - time >= TimeInterval(Int64.min), - time <= TimeInterval(Int64.max) else { - throw CmxIrohRelayPolicyError.invalidClaims - } - let nowSeconds = Int64(time.rounded(.down)) - let futureTolerance = nowSeconds.addingReportingOverflow(30) - let lifetime = policy.expiresAt.subtractingReportingOverflow(policy.issuedAt) - let notBeforeFloor = policy.issuedAt.subtractingReportingOverflow(30) - guard !futureTolerance.overflow, - !lifetime.overflow, - !notBeforeFloor.overflow, - policy.version == 1, - UUID(uuidString: policy.policyID)?.uuidString.lowercased() == policy.policyID, - policy.sequence > 0, - policy.audience == audience, - policy.notBefore >= notBeforeFloor.partialValue, - policy.notBefore <= futureTolerance.partialValue, - policy.expiresAt > policy.notBefore, - lifetime.partialValue > 0, - lifetime.partialValue <= maximumLifetime, - policy.issuedAt <= futureTolerance.partialValue, - (1 ... maximumRelayCount).contains(policy.relays.count), - Set(policy.relays.map(\.id)).count == policy.relays.count, - Set(policy.relays.map(\.url)).count == policy.relays.count, - policy.relays.allSatisfy(validRelay) else { - throw CmxIrohRelayPolicyError.invalidClaims - } - guard policy.relayProtocol == relayProtocol else { - throw CmxIrohRelayPolicyError.unsupportedRelayProtocol - } - // Distributed clients and the signing service do not share a clock. - // Apply the same bounded skew allowance already required for `iat` so - // a freshly issued policy cannot fail merely because the server is a - // few seconds ahead, while policies beyond the 30-second window still - // fail closed as invalid claims above. - guard policy.notBefore <= futureTolerance.partialValue else { - throw CmxIrohRelayPolicyError.notYetValid - } - guard policy.expiresAt > nowSeconds else { - throw CmxIrohRelayPolicyError.expired - } - } - - private static func validRelay(_ relay: CmxIrohManagedRelayDescriptor) -> Bool { - isSafeIdentifier(relay.id) - && isSafeLabel(relay.provider) - && isSafeLabel(relay.region) - && isCanonicalManagedRelayURL(relay.url) - } - - private static func isSafeIdentifier(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } - - private static func isSafeLabel(_ value: String) -> Bool { - guard (1 ... 80).contains(value.utf8.count), - value.utf8.first != 32, - value.utf8.last != 32 else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [32, 45, 46, 95].contains(byte) - } - } - - private static func isCanonicalManagedRelayURL(_ value: String) -> Bool { - guard let components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port.map({ (1 ... 65_535).contains($0) }) ?? true, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path == "/" else { - return false - } - return components.string == value - } - - private static func requireExactPolicyShape(_ data: Data) throws { - let expected: Set<String> = [ - "version", "jti", "sequence", "iat", "nbf", "exp", "aud", - "relay_protocol", "relays", - ] - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - Set(object.keys) == expected, - let relays = object["relays"] as? [[String: Any]], - relays.allSatisfy({ Set($0.keys) == ["id", "provider", "region", "url"] }) else { - throw CmxIrohRelayPolicyError.invalidClaims - } - } - - private static func requireExactKeys(_ data: Data, expected: Set<String>) throws { - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - Set(object.keys) == expected else { - throw CmxIrohRelayPolicyError.invalidHeader - } - } - - private static func decodeBase64URL(_ value: String) -> Data? { - guard !value.isEmpty, - value.utf8.allSatisfy({ byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || byte == 45 || byte == 95 - }) else { - return nil - } - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - let standard = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - guard let data = Data(base64Encoded: standard), base64URL(data) == value else { - return nil - } - return data - } - - private static func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceResponse.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceResponse.swift deleted file mode 100644 index 8501fbe9..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceResponse.swift +++ /dev/null @@ -1,35 +0,0 @@ -/// Current account relay preference returned by the broker. -public struct CmxIrohRelayPreferenceResponse: Codable, Equatable, Sendable { - private enum CodingKeys: String, CodingKey { - case preference - case revision = "preferenceRevision" - } - - /// Complete current account configuration. - public let preference: CmxIrohAccountRelayConfiguration - - /// Monotonic preference revision. - public let revision: Int64 - - /// Creates a validated preference response. - public init(preference: CmxIrohAccountRelayConfiguration, revision: Int64) throws { - guard revision >= 0 else { throw CmxIrohRelayPolicyError.invalidClaims } - self.preference = preference - self.revision = revision - } - - /// Decodes and revalidates one preference response. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - do { - try self.init( - preference: container.decode(CmxIrohAccountRelayConfiguration.self, forKey: .preference), - revision: container.decode(Int64.self, forKey: .revision) - ) - } catch { - throw DecodingError.dataCorrupted( - .init(codingPath: decoder.codingPath, debugDescription: "Invalid preference response") - ) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceStore.swift deleted file mode 100644 index a926ca54..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceStore.swift +++ /dev/null @@ -1,132 +0,0 @@ -public import Foundation - -/// Secure account-scoped cache for requested and effective relay preferences. -public actor CmxIrohRelayPreferenceStore { - private struct Record: Codable { - let version: Int - let preference: CmxIrohPersistedRelayPreference - } - - private static let recordVersion = 2 - private let secureStore: any CmxIrohSecureCredentialStoring - private var busyAccounts: Set<String> = [] - private var accountWaiters: [String: [CheckedContinuation<Void, Never>]] = [:] - - /// Creates an isolated preference cache. - public init( - secureStore: any CmxIrohSecureCredentialStoring = CmxIrohKeychainCredentialStore( - service: "com.cmuxterm.iroh.relay-preference.v1" - ) - ) { - self.secureStore = secureStore - } - - /// Installs one preference revision with rollback and equivocation protection. - @discardableResult - public func install( - requested: CmxIrohAccountRelayConfiguration, - effective: CmxIrohAccountRelayPreference?, - revision: Int64, - effectivePolicySequence: Int64?, - staleRelayIDs: Set<String>, - accountID: String - ) async throws -> CmxIrohPersistedRelayPreference { - guard revision >= 0, - effectivePolicySequence.map({ $0 > 0 }) ?? true, - staleRelayIDs.count <= CmxIrohRelayPolicyVerifier.maximumRelayCount else { - throw CmxIrohRelayPolicyError.invalidClaims - } - _ = try JSONEncoder().encode(requested) - if let effective { _ = try JSONEncoder().encode(effective) } - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "relay-preference" - ) - await acquire(account) - defer { release(account) } - let existing = try await storedRecord(account: account)?.preference - if let existing { - guard revision > existing.revision - || (revision == existing.revision && requested == existing.requested) else { - throw CmxIrohRelayPolicyServiceError.preferenceRollback - } - } - let preference = CmxIrohPersistedRelayPreference( - requested: requested, - effective: effective, - revision: revision, - effectivePolicySequence: effectivePolicySequence, - staleRelayIDs: staleRelayIDs - ) - try await secureStore.write( - JSONEncoder().encode(Record(version: Self.recordVersion, preference: preference)), - account: account, - accessibility: .afterFirstUnlockThisDeviceOnly - ) - return preference - } - - /// Loads the last validated preference for one authenticated account. - public func load(accountID: String) async throws -> CmxIrohPersistedRelayPreference? { - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "relay-preference" - ) - await acquire(account) - defer { release(account) } - return try await storedRecord(account: account)?.preference - } - - /// Removes the cached preference for one authenticated account. - public func deactivate(accountID: String) async throws { - let account = try CmxIrohRelayStorageScope.account( - accountID, - prefix: "relay-preference" - ) - await acquire(account) - defer { release(account) } - try await secureStore.delete(account: account) - } - - private func acquire(_ account: String) async { - guard busyAccounts.contains(account) else { - busyAccounts.insert(account) - return - } - await withCheckedContinuation { continuation in - accountWaiters[account, default: []].append(continuation) - } - } - - private func release(_ account: String) { - guard var waiters = accountWaiters[account], !waiters.isEmpty else { - busyAccounts.remove(account) - accountWaiters.removeValue(forKey: account) - return - } - let next = waiters.removeFirst() - if waiters.isEmpty { - accountWaiters.removeValue(forKey: account) - } else { - accountWaiters[account] = waiters - } - next.resume() - } - - private func storedRecord(account: String) async throws -> Record? { - guard let data = try await secureStore.read(account: account) else { return nil } - guard let record = try? JSONDecoder().decode(Record.self, from: data), - (1 ... Self.recordVersion).contains(record.version), - record.preference.revision >= 0, - record.preference.effectivePolicySequence.map({ $0 > 0 }) ?? true, - record.preference.staleRelayIDs.count - <= CmxIrohRelayPolicyVerifier.maximumRelayCount, - (try? JSONEncoder().encode(record.preference.requested)) != nil, - record.preference.effective.map({ - (try? JSONEncoder().encode($0)) != nil - }) ?? true else { - throw CmxIrohRelayPolicyError.invalidClaims - } - return record - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceUpdateRequest.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceUpdateRequest.swift deleted file mode 100644 index 15534f6d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPreferenceUpdateRequest.swift +++ /dev/null @@ -1,20 +0,0 @@ -/// Optimistic-concurrency request for an account relay preference update. -public struct CmxIrohRelayPreferenceUpdateRequest: Encodable, Equatable, Sendable { - /// Last observed revision, or `nil` when creating the first preference. - public let expectedRevision: Int64? - - /// Replacement account configuration. - public let preference: CmxIrohAccountRelayConfiguration - - /// Creates a validated preference update. - public init( - expectedRevision: Int64?, - preference: CmxIrohAccountRelayConfiguration - ) throws { - guard expectedRevision.map({ $0 >= 0 }) ?? true else { - throw CmxIrohRelayPolicyError.invalidClaims - } - self.expectedRevision = expectedRevision - self.preference = preference - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayStorageScope.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayStorageScope.swift deleted file mode 100644 index 83460ec8..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayStorageScope.swift +++ /dev/null @@ -1,29 +0,0 @@ -import CryptoKit -import Foundation - -enum CmxIrohRelayStorageScope { - static func account(_ accountID: String, prefix: String) throws -> String { - guard CmxIrohPendingRevocation.isSafeAccountID(accountID) else { - throw CmxIrohRelayPolicyError.invalidClaims - } - let digest = SHA256.hash(data: Data(accountID.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return "\(prefix)-\(digest)" - } - - static func isSafeToken(_ value: String) -> Bool { - (1 ... 8 * 1_024).contains(value.utf8.count) - && !value.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7f } - } - - static func isSafeRelayID(_ value: String) -> Bool { - guard (1 ... 64).contains(value.utf8.count) else { return false } - return value.utf8.allSatisfy { byte in - (48 ... 57).contains(byte) - || (65 ... 90).contains(byte) - || (97 ... 122).contains(byte) - || [45, 46, 95].contains(byte) - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift deleted file mode 100644 index 2d18da34..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenResponse.swift +++ /dev/null @@ -1,115 +0,0 @@ -public import Foundation - -/// Endpoint-scoped credentials for one exact managed relay fleet. -public struct CmxIrohRelayTokenResponse: Codable, Equatable, Sendable { - /// URL-keyed relay credentials returned by the broker. - public let credentials: [CmxIrohManagedRelayCredential] - - /// The complete ordered managed relay fleet covered by the response. - public var relayFleet: [String] { - credentials.map(\.relayURL) - } - - /// Creates a response containing independently issued relay credentials. - /// - /// - Parameter credentials: One credential for every signed managed relay. - public init(credentials: [CmxIrohManagedRelayCredential]) { - self.credentials = credentials - } - - /// Creates a legacy homogeneous-fleet response for cache and API migration. - /// - /// New broker responses should use ``init(credentials:)``. This initializer - /// remains so a single legacy token can be expanded into the URL-keyed model. - /// - /// - Parameters: - /// - token: One token accepted by every relay in `relayFleet`. - /// - expiresAt: The shared provider-enforced expiry in ISO 8601 format. - /// - refreshAfter: The shared replacement time in ISO 8601 format. - /// - relayFleet: The complete managed relay fleet covered by the token. - public init( - token: String, - expiresAt: String, - refreshAfter: String, - relayFleet: [String] - ) { - credentials = relayFleet.map { - CmxIrohManagedRelayCredential( - relayURL: $0, - token: token, - expiresAt: expiresAt, - refreshAfter: refreshAfter - ) - } - } - - /// Decodes the URL-keyed wire format or the legacy homogeneous-fleet format. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - if container.contains(.credentials) { - self.init( - credentials: try container.decode( - [CmxIrohManagedRelayCredential].self, - forKey: .credentials - ) - ) - return - } - let token = try container.decode(String.self, forKey: .token) - let expiresAt = try container.decode(String.self, forKey: .expiresAt) - let refreshAfter = try container.decode(String.self, forKey: .refreshAfter) - let relayFleet = try container.decode([String].self, forKey: .relayFleet) - self.init( - token: token, - expiresAt: expiresAt, - refreshAfter: refreshAfter, - relayFleet: relayFleet - ) - } - - /// Encodes only the URL-keyed format so newly persisted state is unambiguous. - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(credentials, forKey: .credentials) - } - - /// Validates every URL-token association and creates endpoint credentials. - /// - /// - Parameter now: The validation time. - /// - Returns: One configuration for every unique relay URL. - /// - Throws: A coarse invalid-response error for malformed, stale, duplicate, - /// or over-sized credential sets. - public func relayConfigurations(now: Date) throws -> [CmxIrohRelayConfiguration] { - guard (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - credentials.count - ), - Set(credentials.map(\.relayURL)).count == credentials.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - do { - return try credentials.map { credential in - guard let expiresAt = CmxIrohISO8601Date.parse(credential.expiresAt), - let refreshAfter = CmxIrohISO8601Date.parse(credential.refreshAfter) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return try CmxIrohRelayConfiguration( - url: credential.relayURL, - token: credential.token, - expiresAt: expiresAt, - refreshAfter: refreshAfter, - now: now - ) - } - } catch { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - } - - private enum CodingKeys: String, CodingKey { - case credentials = "relay_credentials" - case token - case expiresAt = "expires_at" - case refreshAfter = "refresh_after" - case relayFleet = "relay_fleet" - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift deleted file mode 100644 index 3b7e0e36..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayTokenServing.swift +++ /dev/null @@ -1,12 +0,0 @@ -public import CMUXMobileCore - -/// Narrow trust-broker boundary used by relay credential rotation. -public protocol CmxIrohRelayTokenServing: Sendable { - /// Issues a fresh endpoint-bound credential for the managed relay fleet. - func issueRelayToken( - bindingID: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse -} - -extension CmxIrohTrustBrokerClient: CmxIrohRelayTokenServing {} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohResourceID.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohResourceID.swift deleted file mode 100644 index a23bca6d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohResourceID.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// A bounded opaque identifier for a terminal, artifact, or pairing invitation. -public struct CmxIrohResourceID: Equatable, Hashable, Sendable { - /// The canonical ASCII identifier. - public let value: String - - /// Creates a validated resource identifier. - /// - /// Identifiers contain 1 through 128 ASCII letters, digits, dots, colons, - /// underscores, or hyphens. They never carry user-visible names. - /// - /// - Parameter value: The opaque identifier to validate. - /// - Throws: ``CmxIrohResourceIDError/invalidValue`` for unsafe values. - public init(_ value: String) throws { - let bytes = Array(value.utf8) - guard (1 ... 128).contains(bytes.count), bytes.allSatisfy(Self.isAllowed) else { - throw CmxIrohResourceIDError.invalidValue - } - self.value = value - } - - private static func isAllowed(_ byte: UInt8) -> Bool { - switch byte { - case UInt8(ascii: "A") ... UInt8(ascii: "Z"), - UInt8(ascii: "a") ... UInt8(ascii: "z"), - UInt8(ascii: "0") ... UInt8(ascii: "9"), - UInt8(ascii: "."), UInt8(ascii: ":"), UInt8(ascii: "_"), UInt8(ascii: "-"): - true - default: - false - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohResourceIDError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohResourceIDError.swift deleted file mode 100644 index 6e279d93..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohResourceIDError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Validation failures for identifiers carried in Iroh stream headers. -public enum CmxIrohResourceIDError: Error, Equatable, Sendable { - /// The identifier is empty, too long, or contains a non-protocol character. - case invalidValue -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRetrySchedule.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRetrySchedule.swift deleted file mode 100644 index 9ece80aa..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRetrySchedule.swift +++ /dev/null @@ -1,53 +0,0 @@ -public import Foundation - -/// Computes bounded exponential retry delays with a server-provided floor. -public struct CmxIrohRetrySchedule: Equatable, Sendable { - /// The first retry delay before jitter. - public let initialDelay: TimeInterval - - /// The largest accepted delay, including a validated `Retry-After` floor. - public let maximumDelay: TimeInterval - - /// The positive jitter fraction applied above the retry floor. - public let jitterFraction: Double - - /// Creates a bounded exponential retry schedule. - /// - /// - Parameters: - /// - initialDelay: The first retry delay before jitter. - /// - maximumDelay: The hard delay cap. - /// - jitterFraction: The maximum positive jitter as a fraction of the floor. - public init( - initialDelay: TimeInterval = 30, - maximumDelay: TimeInterval = 3_600, - jitterFraction: Double = 0.25 - ) { - let normalizedMaximumDelay = max(1, maximumDelay) - self.initialDelay = min(normalizedMaximumDelay, max(1, initialDelay)) - self.maximumDelay = normalizedMaximumDelay - self.jitterFraction = min(1, max(0, jitterFraction)) - } - - /// Returns a retry delay that never precedes a server-provided floor. - /// - /// - Parameters: - /// - failureCount: Zero-based consecutive failure count. - /// - retryAfterSeconds: A validated server retry floor, when available. - /// - jitterUnitInterval: A deterministic value from zero through one. - /// - Returns: A positive delay bounded by ``maximumDelay``. - public func delay( - failureCount: Int, - retryAfterSeconds: Int?, - jitterUnitInterval: Double - ) -> TimeInterval { - let boundedFailureCount = min(max(0, failureCount), 20) - let exponential = initialDelay * pow(2, Double(boundedFailureCount)) - let base = min(maximumDelay, exponential) - let serverFloor = retryAfterSeconds.map(TimeInterval.init) ?? 0 - let floor = min(maximumDelay, max(base, serverFloor)) - let jitter = min(1, max(0, jitterUnitInterval)) - let available = max(0, maximumDelay - floor) - let jitterWindow = min(available, floor * jitterFraction) - return floor + jitterWindow * jitter - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeContextRouter.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeContextRouter.swift deleted file mode 100644 index 6dfaa710..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeContextRouter.swift +++ /dev/null @@ -1,43 +0,0 @@ -import CMUXMobileCore - -/// Defers client dials until the runtime installs one exact verified local binding. -actor CmxIrohRuntimeContextRouter: CmxIrohClientContextProvider { - private var provider: (any CmxIrohClientContextProvider)? - - func install(_ provider: any CmxIrohClientContextProvider) { - self.provider = provider - } - - func clear() { - provider = nil - } - - func context(for request: CmxByteTransportRequest) async throws -> CmxIrohClientContext { - guard let provider else { - throw CmxIrohRegistryContextError.localBindingUnavailable - } - return try await provider.context(for: request) - } - - func contextWithPrivateFallback( - for request: CmxByteTransportRequest, - basedOn context: CmxIrohClientContext - ) async throws -> CmxIrohClientContext { - guard let provider else { - throw CmxIrohRegistryContextError.localBindingUnavailable - } - return try await provider.contextWithPrivateFallback( - for: request, - basedOn: context - ) - } - - func validatePrivateFallback( - _ authorization: CmxIrohPrivateFallbackAuthorization - ) async throws { - guard let provider else { - throw CmxIrohPrivateFallbackValidationError.unavailable - } - try await provider.validatePrivateFallback(authorization) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift deleted file mode 100644 index 77216566..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRuntimeRelayProfile.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation - -extension CmxIrohHostRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -extension CmxIrohClientRuntimeConfiguration { - func resolvedEndpointRelayProfile(now: Date) throws -> CmxIrohEndpointRelayProfile { - try resolveEndpointRelayProfile( - configured: endpointRelayProfile, - managedRelayURLs: managedRelayURLs, - cachedRelayCredential: cachedRelayCredential, - now: now - ) - } -} - -private func resolveEndpointRelayProfile( - configured: CmxIrohEndpointRelayProfile?, - managedRelayURLs: Set<String>, - cachedRelayCredential: CmxIrohRelayTokenResponse?, - now: Date -) throws -> CmxIrohEndpointRelayProfile { - let base = try configured ?? CmxIrohEndpointRelayProfile( - managedRelayURLs: managedRelayURLs, - relays: [] - ) - guard base.source == .managed, - let cachedRelayCredential, - cachedRelayCredential.relayFleet.count == managedRelayURLs.count, - Set(cachedRelayCredential.relayFleet) == managedRelayURLs, - let cached = try? cachedRelayCredential.relayConfigurations(now: now) else { - return base - } - let selected = cached.filter { base.allowedRelayURLs.contains($0.url) } - guard selected.count == base.allowedRelayURLs.count else { return base } - return try base.replacingManagedRelays(selected) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecretKey.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecretKey.swift deleted file mode 100644 index 8ed234cd..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecretKey.swift +++ /dev/null @@ -1,18 +0,0 @@ -public import Foundation - -/// A validated 32-byte Ed25519 secret used to preserve an Iroh EndpointID. -public struct CmxIrohSecretKey: Equatable, Sendable { - /// The raw secret bytes supplied only to the endpoint factory. - public let bytes: Data - - /// Creates a validated endpoint secret. - /// - /// - Parameter bytes: Exactly 32 random bytes from device-local Keychain storage. - /// - Throws: ``CmxIrohSecretKeyError/invalidByteCount(_:)`` for any other size. - public init(bytes: Data) throws { - guard bytes.count == 32 else { - throw CmxIrohSecretKeyError.invalidByteCount(bytes.count) - } - self.bytes = bytes - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecretKeyError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecretKeyError.swift deleted file mode 100644 index 1d75dab6..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecretKeyError.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Validation failures for a persisted Iroh endpoint secret. -public enum CmxIrohSecretKeyError: Error, Equatable, Sendable { - /// Iroh endpoint secrets are exactly 32 bytes. - case invalidByteCount(Int) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureCredentialAccessibility.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureCredentialAccessibility.swift deleted file mode 100644 index 713a0254..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureCredentialAccessibility.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// The data-protection policy applied to an Iroh capability in Keychain. -public enum CmxIrohSecureCredentialAccessibility: Equatable, Sendable { - /// Available after the first device unlock and excluded from migration to another device. - case afterFirstUnlockThisDeviceOnly -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureCredentialStoring.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureCredentialStoring.swift deleted file mode 100644 index ab5e87f1..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureCredentialStoring.swift +++ /dev/null @@ -1,30 +0,0 @@ -public import Foundation - -/// Secure persistence boundary for short-lived Iroh capability records. -public protocol CmxIrohSecureCredentialStoring: Sendable { - /// Loads the record for an opaque account scope. - /// - /// - Parameter account: A repository-derived scope that contains no account identifier. - /// - Returns: The stored capability record, or `nil` when none exists. - func read(account: String) async throws -> Data? - - /// Replaces the record for an opaque account scope. - /// - /// - Parameters: - /// - data: The capability record to store. - /// - account: A repository-derived scope that contains no account identifier. - /// - accessibility: The required Keychain data-protection policy. - func write( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility - ) async throws - - /// Removes one opaque account scope. - /// - /// - Parameter account: The repository-derived scope to remove. - func delete(account: String) async throws - - /// Removes every Iroh capability owned by this app installation. - func deleteAll() async throws -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureIdentityStoring.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureIdentityStoring.swift deleted file mode 100644 index fb4f3448..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSecureIdentityStoring.swift +++ /dev/null @@ -1,16 +0,0 @@ -public import Foundation - -/// Minimal secure-storage boundary used by the Iroh identity repository. -public protocol CmxIrohSecureIdentityStoring: Sendable { - /// Loads the record for an opaque account scope. - func read(account: String) throws -> Data? - - /// Replaces the record for an opaque account scope. - func write(_ data: Data, account: String) throws - - /// Removes one opaque account scope. - func delete(account: String) throws - - /// Removes every Iroh identity owned by this app installation. - func deleteAll() throws -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSelectedTransportPathClassifier.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSelectedTransportPathClassifier.swift deleted file mode 100644 index c7461930..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSelectedTransportPathClassifier.swift +++ /dev/null @@ -1,50 +0,0 @@ -import CMUXMobileCore - -/// Maps package-private path evidence through one verified effective policy. -struct CmxIrohSelectedTransportPathClassifier: Sendable { - private let policy: CmxIrohEffectiveRelayPolicy? - - init(policy: CmxIrohEffectiveRelayPolicy?) { - self.policy = policy - } - - func classify( - _ observedPath: CmxIrohObservedConnectionPath - ) -> CmxIrohSelectedTransportPath { - switch observedPath { - case .unavailable: - return .unavailable - case .direct: - return .direct - case .privateNetwork: - return .privateNetwork - case let .relay(url): - return classifyRelay(url: url) - } - } - - private func classifyRelay(url: String) -> CmxIrohSelectedTransportPath { - guard let policy, - policy.endpointRelayProfile.allowedRelayURLs.contains(url) else { - return .unavailable - } - switch policy.source { - case .managed: - if let relay = policy.managedPolicy?.relays.first(where: { $0.url == url }) { - return .managedRelay(provider: relay.provider, region: relay.region) - } - case .custom: - if case let .custom(relays) = policy.effectivePreference, - let relay = relays.first(where: { $0.url == url }) { - return .customRelay( - displayName: relay.displayName ?? relay.id, - provider: relay.provider, - region: relay.region - ) - } - case .inactive, .managedUnavailable, .customUnavailable: - break - } - return .unavailable - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSendStream.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSendStream.swift deleted file mode 100644 index f4786e58..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSendStream.swift +++ /dev/null @@ -1,26 +0,0 @@ -public import Foundation - -/// The writable half of one Iroh QUIC stream. -public protocol CmxIrohSendStream: Sendable { - /// Writes the complete buffer with QUIC backpressure. - /// - /// - Parameter data: The application bytes to send. - /// - Throws: A transport error or `CancellationError`. - func send(_ data: Data) async throws - - /// Gracefully finishes the send direction. - /// - /// - Throws: A transport error when the peer has already stopped the stream. - func finish() async throws - - /// Aborts the send direction. - /// - /// - Parameter errorCode: The application error code carried by QUIC. - func reset(errorCode: UInt64) async - - /// Assigns relative scheduling priority within the QUIC connection. - /// - /// - Parameter priority: The Iroh stream priority value. - /// - Throws: A transport error when the stream is no longer writable. - func setPriority(_ priority: Int32) async throws -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerByteTransport.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerByteTransport.swift deleted file mode 100644 index af483f68..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerByteTransport.swift +++ /dev/null @@ -1,41 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Presents one already-admitted Iroh control stream through the shared RPC byte seam. -public actor CmxIrohServerByteTransport: CmxByteTransport { - private let session: CmxIrohServerSession - private var connected = false - private var closed = false - - public init(session: CmxIrohServerSession) { - self.session = session - } - - public func connect() async throws { - guard !closed else { throw CmxIrohServerSessionError.alreadyClosed } - _ = try await session.admittedPeerContext() - connected = true - } - - public func receive() async throws -> Data? { - try requireConnected() - return try await session.receiveControl() - } - - public func send(_ data: Data) async throws { - try requireConnected() - try await session.sendControl(data) - } - - public func close() async { - guard !closed else { return } - closed = true - connected = false - await session.close() - } - - private func requireConnected() throws { - guard !closed else { throw CmxIrohServerSessionError.alreadyClosed } - guard connected else { throw CmxIrohServerSessionError.notAdmitted } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift deleted file mode 100644 index e0d1339c..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSession.swift +++ /dev/null @@ -1,355 +0,0 @@ -public import Foundation - -/// An admitted Mac-side multistream session over one TLS-authenticated Iroh connection. -public actor CmxIrohServerSession { - private struct HeaderReadResult: Sendable { - let header: CmxIrohStreamHeader - let trailingBytes: Data - } - - private let connection: any CmxIrohConnection - private let authorizer: any CmxIrohAdmissionAuthorizing - private let protocolConfiguration: CmxIrohProtocolConfiguration - private let headerCodec: CmxIrohStreamHeaderCodec - private let admissionCodec = CmxIrohAdmissionAckCodec() - private let streamHeaderClock: any CmxIrohRelayClock - private let streamHeaderTimeout: TimeInterval - private var controlStream: CmxIrohBidirectionalStream? - private var controlReceiveBuffer = Data() - private var admittedPeer: CmxIrohAdmittedPeer? - private var onlineAdmissionLease: CmxIrohOnlineAdmissionLease? - private var admissionInProgress = false - private var admitted = false - private var closed = false - - public init( - connection: any CmxIrohConnection, - authorizer: any CmxIrohAdmissionAuthorizing, - protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1, - streamHeaderClock: any CmxIrohRelayClock = CmxIrohSystemRelayClock(), - streamHeaderTimeout: TimeInterval = 5 - ) throws { - precondition(streamHeaderTimeout > 0) - self.connection = connection - self.authorizer = authorizer - self.protocolConfiguration = protocolConfiguration - self.streamHeaderClock = streamHeaderClock - self.streamHeaderTimeout = streamHeaderTimeout - headerCodec = try CmxIrohStreamHeaderCodec(configuration: protocolConfiguration) - } - - /// Accepts exactly one credential-bearing control stream before any other lane. - @discardableResult - public func admit() async throws -> CmxIrohAdmittedPeer { - guard !closed else { throw CmxIrohServerSessionError.alreadyClosed } - guard !admitted, !admissionInProgress, controlStream == nil else { - throw CmxIrohServerSessionError.alreadyAdmitted - } - admissionInProgress = true - defer { admissionInProgress = false } - // Keep only the bootstrap control stream available until both peers - // finish the NAT-authorization barrier. - let stream: CmxIrohBidirectionalStream - do { - try await connection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 1, - maximumUnidirectionalStreamCount: 0 - ) - stream = try await connection.acceptBidirectionalStream() - } catch { - await connection.close(errorCode: 1, reason: "invalid_control_stream") - closed = true - throw error - } - do { - let decoded = try await Self.readHeader( - from: stream.receiveStream, - headerCodec: headerCodec - ) - guard decoded.header.lane == .control, - let credential = decoded.header.credential else { - throw CmxIrohServerSessionError.invalidFirstLane - } - let peerID = await connection.remoteIdentity() - let authorization = await authorizer.authorize( - credential: credential, - authenticatedPeerID: peerID - ) - let checkedAuthorization: CmxIrohAdmissionAuthorization - switch authorization { - case let .accepted(peer, onlineLease) - where peer.endpointID == peerID && peer.platform == .ios: - checkedAuthorization = .accepted(peer, onlineLease: onlineLease) - case .accepted: - checkedAuthorization = .denied(code: 1) - case .denied: - checkedAuthorization = authorization - } - let initialAdmissionFrame: Data = switch checkedAuthorization { - case .accepted: - admissionCodec.encodeFrame( - protocolConfiguration.allowsNATTraversalAfterAdmission - ? .acceptedPendingNatTraversal - : .acceptedRelayOnly - ) - case .denied: - admissionCodec.encode(checkedAuthorization.wireDecision) - } - try await stream.sendStream.send(initialAdmissionFrame) - switch checkedAuthorization { - case let .accepted(peer, onlineLease): - let clientReady = try await readAdmissionFrame( - from: stream.receiveStream, - initialBuffer: decoded.trailingBytes - ) - guard clientReady.frame == .clientReady else { - throw CmxIrohServerSessionError.invalidAdmissionFrame - } - if protocolConfiguration.allowsNATTraversalAfterAdmission { - try Task.checkCancellation() - try await connection.authorizeNatTraversal() - } - try Task.checkCancellation() - try await stream.sendStream.send( - admissionCodec.encodeFrame(.serverReady) - ) - try Task.checkCancellation() - let applicationLaneCount = protocolConfiguration - .maximumConcurrentClientApplicationLaneCount - if applicationLaneCount > 0 { - try await connection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 1 + applicationLaneCount, - maximumUnidirectionalStreamCount: 0 - ) - try Task.checkCancellation() - } - admitted = true - admittedPeer = peer - onlineAdmissionLease = onlineLease - controlStream = stream - controlReceiveBuffer = clientReady.trailingBytes - return peer - case let .denied(code): - await stream.sendStream.reset(errorCode: 1) - await stream.receiveStream.stop(errorCode: 1) - await connection.close(errorCode: 1, reason: "admission_denied") - closed = true - throw CmxIrohServerSessionError.admissionDenied(code: code) - } - } catch { - if !admitted, !closed { - await stream.sendStream.reset(errorCode: 1) - await stream.receiveStream.stop(errorCode: 1) - await connection.close(errorCode: 1, reason: "invalid_control_stream") - closed = true - } - throw error - } - } - - public func receiveControl( - maximumByteCount: Int = 64 * 1_024 - ) async throws -> Data? { - guard maximumByteCount > 0 else { - throw CmxIrohServerSessionError.unexpectedEndOfStream - } - let stream = try admittedControlStream() - if !controlReceiveBuffer.isEmpty { - let count = min(maximumByteCount, controlReceiveBuffer.count) - let value = Data(controlReceiveBuffer.prefix(count)) - controlReceiveBuffer.removeFirst(count) - return value - } - return try await stream.receiveStream.receive(maximumByteCount: maximumByteCount) - } - - public func sendControl(_ data: Data) async throws { - try await admittedControlStream().sendStream.send(data) - } - - /// Returns the exact binding retained when this control stream was admitted. - public func admittedPeerContext() throws -> CmxIrohAdmittedPeer { - try requireAdmitted() - guard let admittedPeer else { throw CmxIrohServerSessionError.notAdmitted } - return admittedPeer - } - - /// Returns the online revocation lease retained during admission, when applicable. - public func admittedOnlineLease() throws -> CmxIrohOnlineAdmissionLease? { - try requireAdmitted() - return onlineAdmissionLease - } - - /// Accepts a client-created terminal or artifact bidirectional lane. - public func acceptBidirectionalLane() async throws -> ( - lane: CmxIrohLane, - stream: CmxIrohBidirectionalStream - ) { - try requireAdmitted() - guard protocolConfiguration.maximumConcurrentClientApplicationLaneCount > 0 else { - throw CmxIrohServerSessionError.applicationLanesUnavailable - } - let stream = try await connection.acceptBidirectionalStream() - do { - let decoded = try await readApplicationHeader( - from: stream.receiveStream - ) - switch decoded.header.lane { - case .terminal, .artifact: - break - case .control, .serverEvents: - throw CmxIrohServerSessionError.invalidPeerLane - } - let buffered = CmxIrohBufferedReceiveStream( - base: stream.receiveStream, - buffer: decoded.trailingBytes - ) - return ( - decoded.header.lane, - CmxIrohBidirectionalStream( - receiveStream: buffered, - sendStream: stream.sendStream - ) - ) - } catch is CancellationError { - await stream.sendStream.reset(errorCode: 1) - await stream.receiveStream.stop(errorCode: 1) - throw CancellationError() - } catch { - await stream.sendStream.reset(errorCode: 1) - await stream.receiveStream.stop(errorCode: 1) - throw CmxIrohServerSessionError.applicationLaneRejected - } - } - - /// Opens the centrally owned server-event lane with its header prewritten. - public func openSendLane( - _ lane: CmxIrohLane, - priority: Int32 - ) async throws -> any CmxIrohSendStream { - try requireAdmitted() - switch lane { - case .serverEvents: - break - case .artifact: - throw CmxIrohServerSessionError.applicationLanesUnavailable - case .control, .terminal: - throw CmxIrohServerSessionError.invalidServerLane - } - let stream = try await connection.openSendStream() - do { - try await stream.setPriority(priority) - try await stream.send(headerCodec.encode(CmxIrohStreamHeader(lane: lane))) - return stream - } catch { - await stream.reset(errorCode: 1) - throw error - } - } - - public func close() async { - guard !closed else { return } - closed = true - if let controlStream { - await controlStream.sendStream.reset(errorCode: 0) - await controlStream.receiveStream.stop(errorCode: 0) - } - await connection.close(errorCode: 0, reason: "server_closed") - self.controlStream = nil - admittedPeer = nil - onlineAdmissionLease = nil - controlReceiveBuffer.removeAll(keepingCapacity: false) - } - - private func admittedControlStream() throws -> CmxIrohBidirectionalStream { - try requireAdmitted() - guard let controlStream else { throw CmxIrohServerSessionError.notAdmitted } - return controlStream - } - - private func requireAdmitted() throws { - guard !closed else { throw CmxIrohServerSessionError.alreadyClosed } - guard admitted else { throw CmxIrohServerSessionError.notAdmitted } - } - - private func readApplicationHeader( - from receiveStream: any CmxIrohReceiveStream - ) async throws -> HeaderReadResult { - let headerCodec = headerCodec - let clock = streamHeaderClock - let deadline = clock.now().addingTimeInterval(streamHeaderTimeout) - return try await withThrowingTaskGroup( - of: HeaderReadResult.self - ) { group in - group.addTask { - try await Self.readHeader( - from: receiveStream, - headerCodec: headerCodec - ) - } - group.addTask { - try await clock.sleep(until: deadline) - try Task.checkCancellation() - await receiveStream.stop(errorCode: 1) - throw CmxIrohServerSessionError.streamHeaderTimedOut - } - defer { group.cancelAll() } - guard let first = try await group.next() else { - throw CancellationError() - } - return first - } - } - - private static func readHeader( - from receiveStream: any CmxIrohReceiveStream, - headerCodec: CmxIrohStreamHeaderCodec - ) async throws -> HeaderReadResult { - var buffer = Data() - var requestedByteCount = 16 - while true { - if buffer.count >= requestedByteCount { - do { - let decoded = try headerCodec.decodePrefix(buffer) - return HeaderReadResult( - header: decoded.header, - trailingBytes: Data( - buffer.dropFirst(decoded.consumedByteCount) - ) - ) - } catch let error as CmxIrohStreamHeaderCodecError { - if case let .incompleteFrame(requiredByteCount) = error { - requestedByteCount = requiredByteCount - } else { - throw error - } - } - } - let remaining = requestedByteCount - buffer.count - guard let bytes = try await receiveStream.receive(maximumByteCount: remaining), - !bytes.isEmpty else { - throw CmxIrohServerSessionError.unexpectedEndOfStream - } - buffer.append(bytes) - } - } - - private func readAdmissionFrame( - from receiveStream: any CmxIrohReceiveStream, - initialBuffer: Data - ) async throws -> (frame: CmxIrohAdmissionFrame, trailingBytes: Data) { - var buffer = initialBuffer - while buffer.count < CmxIrohAdmissionAckCodec.frameByteCount { - let remaining = CmxIrohAdmissionAckCodec.frameByteCount - buffer.count - guard let bytes = try await receiveStream.receive(maximumByteCount: remaining), - !bytes.isEmpty else { - throw CmxIrohServerSessionError.unexpectedEndOfStream - } - buffer.append(bytes) - } - return ( - try admissionCodec.decodeFramePrefix(buffer), - Data(buffer.dropFirst(CmxIrohAdmissionAckCodec.frameByteCount)) - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSessionError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSessionError.swift deleted file mode 100644 index 166546ad..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohServerSessionError.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// Server-side connection framing and lifecycle failures. -public enum CmxIrohServerSessionError: Error, Equatable, Sendable { - case alreadyAdmitted - case notAdmitted - case alreadyClosed - case unexpectedEndOfStream - case invalidAdmissionFrame - case invalidFirstLane - case invalidPeerLane - case invalidServerLane - case applicationLanesUnavailable - /// One accepted application stream failed framing or lane validation. - /// The stream was reset, but the admitted QUIC session remains usable. - case applicationLaneRejected - case streamHeaderTimedOut - case admissionDenied(code: UInt16) -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredHostPolicyRecord.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredHostPolicyRecord.swift deleted file mode 100644 index d6791343..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredHostPolicyRecord.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// Versioned Keychain envelope for one active account's offline host policy. -struct CmxIrohStoredHostPolicyRecord: Codable, Equatable, Sendable { - static let currentVersion = 2 - - let version: Int - let scopeDigest: String - let policy: CmxIrohCachedHostPolicy - - init(scopeDigest: String, policy: CmxIrohCachedHostPolicy) { - version = Self.currentVersion - self.scopeDigest = scopeDigest - self.policy = policy - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredPendingRevocations.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredPendingRevocations.swift deleted file mode 100644 index d8be1494..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredPendingRevocations.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Versioned device-local storage envelope for one account's pending revocations. -struct CmxIrohStoredPendingRevocations: Codable, Equatable, Sendable { - let version: Int - let entries: [CmxIrohPendingRevocation] -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift deleted file mode 100644 index 7372cf49..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStoredRelayCredential.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation - -/// Versioned Keychain payload for one binding-scoped relay capability. -struct CmxIrohStoredRelayCredential: Codable, Equatable, Sendable { - static let currentVersion = 2 - - let version: Int - let binding: CmxIrohBrokerBindingMetadata - let response: CmxIrohRelayTokenResponse - - init( - binding: CmxIrohBrokerBindingMetadata, - response: CmxIrohRelayTokenResponse - ) { - version = Self.currentVersion - self.binding = binding - self.response = response - } - - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let storedVersion = try container.decode(Int.self, forKey: .version) - binding = try container.decode(CmxIrohBrokerBindingMetadata.self, forKey: .binding) - switch storedVersion { - case 1: - response = CmxIrohRelayTokenResponse( - token: try container.decode(String.self, forKey: .token), - expiresAt: try container.decode(String.self, forKey: .expiresAt), - refreshAfter: try container.decode(String.self, forKey: .refreshAfter), - relayFleet: try container.decode([String].self, forKey: .relayFleet) - ) - case Self.currentVersion: - response = try container.decode( - CmxIrohRelayTokenResponse.self, - forKey: .response - ) - default: - throw DecodingError.dataCorruptedError( - forKey: .version, - in: container, - debugDescription: "Unsupported relay credential version" - ) - } - version = Self.currentVersion - } - - func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(version, forKey: .version) - try container.encode(binding, forKey: .binding) - try container.encode(response, forKey: .response) - } - - private enum CodingKeys: String, CodingKey { - case version - case binding - case response - case token - case expiresAt - case refreshAfter - case relayFleet - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift deleted file mode 100644 index 51c7759e..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeader.swift +++ /dev/null @@ -1,32 +0,0 @@ -/// The authenticated lane declaration at the beginning of every Iroh stream. -public struct CmxIrohStreamHeader: Equatable, Sendable { - /// The application lane carried by the stream. - public let lane: CmxIrohLane - - /// The admission proof, present only on the first control stream. - public let credential: CmxIrohAdmissionCredential? - - /// Creates a validated stream header. - /// - /// - Parameters: - /// - lane: The lane this stream will carry. - /// - credential: The control-stream admission proof. - /// - Throws: ``CmxIrohStreamHeaderError`` for an invalid lane and credential combination. - public init( - lane: CmxIrohLane, - credential: CmxIrohAdmissionCredential? = nil - ) throws { - switch (lane, credential) { - case (.control, nil): - throw CmxIrohStreamHeaderError.missingControlCredential - case (.control, .some): - break - case (_, .some): - throw CmxIrohStreamHeaderError.credentialOnNonControlLane - case (_, nil): - break - } - self.lane = lane - self.credential = credential - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift deleted file mode 100644 index 67ab4ba2..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodec.swift +++ /dev/null @@ -1,280 +0,0 @@ -public import Foundation - -/// Encodes and decodes the bounded binary prefix on every cmux Iroh stream. -public struct CmxIrohStreamHeaderCodec: Sendable { - private static let magic = Data("CMUXIRH1".utf8) - private static let version: UInt8 = 1 - private static let fixedPrefixByteCount = 16 - private static let cursorPresentFlag: UInt8 = 1 - - private let configuration: CmxIrohProtocolConfiguration - - /// Creates a codec for one protocol configuration. - /// - /// - Parameter configuration: The ALPN and hard frame-size limit. - /// - Throws: ``CmxIrohStreamHeaderCodecError/invalidConfiguration`` when the limit is too small. - public init( - configuration: CmxIrohProtocolConfiguration = .cmuxMobileV1 - ) throws { - guard configuration.maximumHeaderByteCount >= Self.fixedPrefixByteCount else { - throw CmxIrohStreamHeaderCodecError.invalidConfiguration - } - self.configuration = configuration - } - - /// Encodes a validated header into its complete binary frame. - /// - /// - Parameter header: The lane declaration to encode. - /// - Returns: The binary header bytes to write before application data. - /// - Throws: ``CmxIrohStreamHeaderCodecError`` when the frame violates a limit. - public func encode(_ header: CmxIrohStreamHeader) throws -> Data { - var payload = Data() - let laneCode: UInt8 - let flags: UInt8 - let credentialCode: UInt8 - - switch header.lane { - case .control: - laneCode = 1 - flags = 0 - guard let credential = header.credential else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - switch credential.kind { - case .pairGrant: - credentialCode = 1 - guard let token = credential.pairGrantToken else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - try appendLengthPrefixedString(token, lengthByteCount: 2, to: &payload) - case .offlinePairing: - credentialCode = 2 - guard let attestation = credential.endpointAttestation, - let invitationID = credential.invitationID, - let proof = credential.offlineProof, - proof.count == 32 - else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - try appendLengthPrefixedString(attestation, lengthByteCount: 2, to: &payload) - try appendLengthPrefixedString(invitationID.value, lengthByteCount: 1, to: &payload) - payload.append(proof) - } - - case let .serverEvents(cursor): - laneCode = 2 - credentialCode = 0 - flags = cursor == nil ? 0 : Self.cursorPresentFlag - if let cursor { - append(cursor, to: &payload) - } - - case let .terminal(resourceID, cursor): - laneCode = 3 - credentialCode = 0 - flags = cursor == nil ? 0 : Self.cursorPresentFlag - try appendLengthPrefixedString(resourceID.value, lengthByteCount: 1, to: &payload) - if let cursor { - append(cursor, to: &payload) - } - - case let .artifact(resourceID, offset): - laneCode = 4 - credentialCode = 0 - flags = 0 - try appendLengthPrefixedString(resourceID.value, lengthByteCount: 1, to: &payload) - append(offset, to: &payload) - } - - let totalByteCount = Self.fixedPrefixByteCount + payload.count - guard totalByteCount <= configuration.maximumHeaderByteCount else { - throw CmxIrohStreamHeaderCodecError.headerTooLarge(totalByteCount) - } - guard let payloadByteCount = UInt32(exactly: payload.count) else { - throw CmxIrohStreamHeaderCodecError.headerTooLarge(totalByteCount) - } - - var frame = Self.magic - frame.append(Self.version) - frame.append(laneCode) - frame.append(flags) - frame.append(credentialCode) - append(payloadByteCount, to: &frame) - frame.append(payload) - return frame - } - - /// Decodes one header prefix while preserving any following application bytes. - /// - /// - Parameter data: Bytes beginning at the start of an Iroh stream. - /// - Returns: The header and exact byte count consumed from `data`. - /// - Throws: ``CmxIrohStreamHeaderCodecError`` or a field validation error. - public func decodePrefix(_ data: Data) throws -> CmxIrohDecodedStreamHeader { - guard data.count >= Self.fixedPrefixByteCount else { - throw CmxIrohStreamHeaderCodecError.incompleteFrame( - requiredByteCount: Self.fixedPrefixByteCount - ) - } - - var prefix = CmxIrohBinaryCursor(data: data.prefix(Self.fixedPrefixByteCount)) - guard try prefix.readData(byteCount: Self.magic.count) == Self.magic else { - throw CmxIrohStreamHeaderCodecError.invalidMagic - } - let version = try prefix.readUInt8() - guard version == Self.version else { - throw CmxIrohStreamHeaderCodecError.unsupportedVersion(version) - } - let laneCode = try prefix.readUInt8() - let flags = try prefix.readUInt8() - let credentialCode = try prefix.readUInt8() - let payloadByteCount = Int(try prefix.readUInt32()) - let totalByteCount = Self.fixedPrefixByteCount + payloadByteCount - guard totalByteCount <= configuration.maximumHeaderByteCount else { - throw CmxIrohStreamHeaderCodecError.headerTooLarge(totalByteCount) - } - guard data.count >= totalByteCount else { - throw CmxIrohStreamHeaderCodecError.incompleteFrame(requiredByteCount: totalByteCount) - } - - let payloadStart = data.index(data.startIndex, offsetBy: Self.fixedPrefixByteCount) - let payloadEnd = data.index(payloadStart, offsetBy: payloadByteCount) - var payload = CmxIrohBinaryCursor(data: data[payloadStart ..< payloadEnd]) - let header = try decodeHeader( - laneCode: laneCode, - flags: flags, - credentialCode: credentialCode, - payload: &payload - ) - guard payload.remainingByteCount == 0 else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - return CmxIrohDecodedStreamHeader( - header: header, - consumedByteCount: totalByteCount - ) - } - - private func decodeHeader( - laneCode: UInt8, - flags: UInt8, - credentialCode: UInt8, - payload: inout CmxIrohBinaryCursor - ) throws -> CmxIrohStreamHeader { - switch laneCode { - case 1: - guard flags == 0 else { - throw CmxIrohStreamHeaderCodecError.invalidFlags(flags) - } - let credential = try decodeCredential(code: credentialCode, payload: &payload) - return try CmxIrohStreamHeader(lane: .control, credential: credential) - case 2: - try validateNonControl(flags: flags, credentialCode: credentialCode) - let cursor = try optionalCursor(flags: flags, payload: &payload) - return try CmxIrohStreamHeader(lane: .serverEvents(cursor: cursor)) - case 3: - try validateNonControl(flags: flags, credentialCode: credentialCode) - let resourceID = try readResourceID(payload: &payload) - let cursor = try optionalCursor(flags: flags, payload: &payload) - return try CmxIrohStreamHeader(lane: .terminal(resourceID: resourceID, cursor: cursor)) - case 4: - guard flags == 0 else { - throw CmxIrohStreamHeaderCodecError.invalidFlags(flags) - } - guard credentialCode == 0 else { - throw CmxIrohStreamHeaderCodecError.invalidCredentialKind(credentialCode) - } - let resourceID = try readResourceID(payload: &payload) - let offset = try payload.readUInt64() - return try CmxIrohStreamHeader(lane: .artifact(resourceID: resourceID, offset: offset)) - default: - throw CmxIrohStreamHeaderCodecError.unknownLane(laneCode) - } - } - - private func decodeCredential( - code: UInt8, - payload: inout CmxIrohBinaryCursor - ) throws -> CmxIrohAdmissionCredential { - switch code { - case 1: - let length = Int(try payload.readUInt16()) - return try .pairGrant(payload.readString(byteCount: length)) - case 2: - let attestationLength = Int(try payload.readUInt16()) - let attestation = try payload.readString(byteCount: attestationLength) - let invitationLength = Int(try payload.readUInt8()) - let invitationID = try CmxIrohResourceID( - payload.readString(byteCount: invitationLength) - ) - let proof = try payload.readData(byteCount: 32) - return try .offlinePairing( - endpointAttestation: attestation, - invitationID: invitationID, - proof: proof - ) - default: - throw CmxIrohStreamHeaderCodecError.invalidCredentialKind(code) - } - } - - private func validateNonControl(flags: UInt8, credentialCode: UInt8) throws { - guard flags & ~Self.cursorPresentFlag == 0 else { - throw CmxIrohStreamHeaderCodecError.invalidFlags(flags) - } - guard credentialCode == 0 else { - throw CmxIrohStreamHeaderCodecError.invalidCredentialKind(credentialCode) - } - } - - private func optionalCursor( - flags: UInt8, - payload: inout CmxIrohBinaryCursor - ) throws -> UInt64? { - flags & Self.cursorPresentFlag == 0 ? nil : try payload.readUInt64() - } - - private func readResourceID( - payload: inout CmxIrohBinaryCursor - ) throws -> CmxIrohResourceID { - let length = Int(try payload.readUInt8()) - return try CmxIrohResourceID(payload.readString(byteCount: length)) - } - - private func appendLengthPrefixedString( - _ value: String, - lengthByteCount: Int, - to data: inout Data - ) throws { - let bytes = Data(value.utf8) - switch lengthByteCount { - case 1: - guard let length = UInt8(exactly: bytes.count) else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - data.append(length) - case 2: - guard let length = UInt16(exactly: bytes.count) else { - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - append(length, to: &data) - default: - throw CmxIrohStreamHeaderCodecError.invalidPayload - } - data.append(bytes) - } - - private func append(_ value: UInt16, to data: inout Data) { - let bigEndian = value.bigEndian - withUnsafeBytes(of: bigEndian) { data.append(contentsOf: $0) } - } - - private func append(_ value: UInt32, to data: inout Data) { - let bigEndian = value.bigEndian - withUnsafeBytes(of: bigEndian) { data.append(contentsOf: $0) } - } - - private func append(_ value: UInt64, to data: inout Data) { - let bigEndian = value.bigEndian - withUnsafeBytes(of: bigEndian) { data.append(contentsOf: $0) } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodecError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodecError.swift deleted file mode 100644 index 19d2a46d..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderCodecError.swift +++ /dev/null @@ -1,29 +0,0 @@ -/// Binary framing failures while encoding or decoding an Iroh stream header. -public enum CmxIrohStreamHeaderCodecError: Error, Equatable, Sendable { - /// The configured frame limit cannot contain even the fixed prefix. - case invalidConfiguration - - /// More bytes are needed before the complete header can be decoded. - case incompleteFrame(requiredByteCount: Int) - - /// The stream did not begin with the cmux Iroh protocol marker. - case invalidMagic - - /// The peer selected a stream-header version this build does not implement. - case unsupportedVersion(UInt8) - - /// The peer selected an unknown lane code. - case unknownLane(UInt8) - - /// Reserved flag bits were set for the selected lane. - case invalidFlags(UInt8) - - /// The credential discriminator is invalid for the selected lane. - case invalidCredentialKind(UInt8) - - /// The declared header exceeds the configured hard limit. - case headerTooLarge(Int) - - /// A length, UTF-8 field, or lane payload violates the binary contract. - case invalidPayload -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderError.swift deleted file mode 100644 index f9e1db69..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohStreamHeaderError.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// Validation failures for a cmux Iroh stream header. -public enum CmxIrohStreamHeaderError: Error, Equatable, Sendable { - /// The control stream did not supply an admission credential. - case missingControlCredential - - /// A non-control stream attempted to carry a second credential. - case credentialOnNonControlLane -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourBrowser.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourBrowser.swift deleted file mode 100644 index ab752ad4..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourBrowser.swift +++ /dev/null @@ -1,344 +0,0 @@ -@preconcurrency import Dispatch -import dnssd -import Foundation - -/// Low-level browser for the single declared cmux Iroh Bonjour service. -public actor CmxIrohSystemBonjourBrowser: CmxIrohBonjourBrowsing { - private struct PendingResolve { - let token: UUID - let operation: any CmxIrohBonjourOperation - let deadlineTask: Task<Void, Never> - } - - private struct QueuedResolve: Sendable { - let regtype: String - let domain: String - } - - private static let defaultMaximumPendingResolves = 16 - private static let defaultResolveTimeout: TimeInterval = 5 - - private let dnsService: any CmxIrohBonjourDNSService - private let clock: any CmxIrohBonjourClock - private let maximumPendingResolves: Int - private let resolveTimeout: TimeInterval - private var browseOperation: (any CmxIrohBonjourOperation)? - private var browseToken: UUID? - private var browseIngress: CmxIrohBonjourBrowseIngress? - private var browseEventTask: Task<Void, Never>? - private var pending: [CmxIrohBonjourServiceID: PendingResolve] = [:] - private var queued: [CmxIrohBonjourServiceID: QueuedResolve] = [:] - private var queuedOrder: [CmxIrohBonjourServiceID] = [] - private var observers: [ - UUID: AsyncStream<CmxIrohBonjourBrowserEvent>.Continuation - ] = [:] - - public init() { - let queue = DispatchQueue(label: "dev.cmux.iroh.bonjour.browser") - dnsService = CmxIrohBonjourSystemDNSService(queue: queue) - clock = CmxIrohSystemBonjourClock() - maximumPendingResolves = Self.defaultMaximumPendingResolves - resolveTimeout = Self.defaultResolveTimeout - } - - init( - dnsService: any CmxIrohBonjourDNSService, - clock: any CmxIrohBonjourClock, - maximumPendingResolves: Int, - resolveTimeout: TimeInterval - ) { - precondition(maximumPendingResolves > 0) - precondition(resolveTimeout.isFinite && resolveTimeout > 0) - self.dnsService = dnsService - self.clock = clock - self.maximumPendingResolves = maximumPendingResolves - self.resolveTimeout = resolveTimeout - } - - public func events() -> AsyncStream<CmxIrohBonjourBrowserEvent> { - let id = UUID() - let stream = AsyncStream( - CmxIrohBonjourBrowserEvent.self, - bufferingPolicy: .bufferingNewest(64) - ) { continuation in - observers[id] = continuation - continuation.onTermination = { [weak self] _ in - Task { await self?.removeObserver(id) } - } - } - if browseOperation == nil { startBrowsing() } - return stream - } - - public func stop() { - stopOperations() - for observer in observers.values { observer.finish() } - observers.removeAll(keepingCapacity: false) - } - - private func startBrowsing() { - let token = UUID() - browseToken = token - let (events, continuation) = AsyncStream.makeStream( - of: CmxIrohBonjourRawBrowseEvent.self, - bufferingPolicy: .bufferingOldest(64) - ) - let ingress = CmxIrohBonjourBrowseIngress(continuation: continuation) - browseIngress = ingress - browseEventTask = Task { [weak self] in - for await event in events { - guard !Task.isCancelled else { break } - await self?.consumeBrowseEvent(token: token, event: event) - ingress.consumed(event.key) - } - } - do { - browseOperation = try dnsService.startBrowse( - serviceType: CmxIrohLANAdvertisement.serviceType, - domain: CmxIrohLANAdvertisement.domain - ) { flags, interfaceIndex, errorCode, name, type, domain in - ingress.offer( - flags: flags, - interfaceIndex: interfaceIndex, - errorCode: errorCode, - serviceName: name, - regtype: type, - domain: domain - ) - } - } catch let error as CmxIrohBonjourDNSServiceError { - ingress.finish() - browseEventTask?.cancel() - browseEventTask = nil - browseIngress = nil - browseToken = nil - publishError(error.code) - } catch { - ingress.finish() - browseEventTask?.cancel() - browseEventTask = nil - browseIngress = nil - browseToken = nil - publishError(Int32(kDNSServiceErr_Unknown)) - } - } - - private func consumeBrowseEvent( - token: UUID, - event: CmxIrohBonjourRawBrowseEvent - ) { - handleBrowse( - token: token, - flags: event.flags, - interfaceIndex: event.interfaceIndex, - errorCode: event.errorCode, - serviceName: event.serviceName, - regtype: event.regtype, - domain: event.domain - ) - } - - private func handleBrowse( - token: UUID, - flags: DNSServiceFlags, - interfaceIndex: UInt32, - errorCode: Int32, - serviceName: String?, - regtype: String?, - domain: String? - ) { - guard browseToken == token else { return } - guard errorCode == kDNSServiceErr_NoError else { - publishError(errorCode) - if errorCode == kDNSServiceErr_PolicyDenied { stopOperations() } - return - } - guard interfaceIndex != 0, - let serviceName, - CmxIrohLANRendezvousAliasGenerator.isCanonicalAlias(serviceName), - let regtype, - let domain, - regtype == "\(CmxIrohLANAdvertisement.serviceType).", - domain == CmxIrohLANAdvertisement.domain else { return } - let id = CmxIrohBonjourServiceID( - serviceName: serviceName, - interfaceIndex: interfaceIndex - ) - let added = flags & DNSServiceFlags(kDNSServiceFlagsAdd) != 0 - if added { - startResolve(id: id, regtype: regtype, domain: domain) - } else { - removeQueuedResolve(id) - stopResolve(id) - publish(.removed(id)) - } - } - - private func startResolve( - id: CmxIrohBonjourServiceID, - regtype: String, - domain: String - ) { - guard pending[id] == nil, queued[id] == nil else { return } - guard pending.count < maximumPendingResolves else { - enqueueResolve(id: id, regtype: regtype, domain: domain) - return - } - startResolveNow(id: id, regtype: regtype, domain: domain) - } - - private func startResolveNow( - id: CmxIrohBonjourServiceID, - regtype: String, - domain: String - ) { - let token = UUID() - do { - let operation = try dnsService.startResolve( - id: id, - regtype: regtype, - domain: domain - ) { [weak self] errorCode, interfaceIndex, host, port, txt in - await self?.handleResolve( - id: id, - token: token, - errorCode: errorCode, - interfaceIndex: interfaceIndex, - hostTarget: host, - port: port, - txtRecord: txt - ) - } - let deadline = clock.now().addingTimeInterval(resolveTimeout) - let clock = clock - let deadlineTask = Task { [weak self] in - do { - try await clock.sleep(until: deadline) - try Task.checkCancellation() - await self?.expireResolve(id: id, token: token) - } catch {} - } - pending[id] = PendingResolve( - token: token, - operation: operation, - deadlineTask: deadlineTask - ) - } catch let error as CmxIrohBonjourDNSServiceError { - publishError(error.code) - drainQueuedResolves() - } catch { - publishError(Int32(kDNSServiceErr_Unknown)) - drainQueuedResolves() - } - } - - private func enqueueResolve( - id: CmxIrohBonjourServiceID, - regtype: String, - domain: String - ) { - let maximumQueuedResolves = max(64, maximumPendingResolves * 4) - guard queued.count < maximumQueuedResolves else { return } - queued[id] = QueuedResolve(regtype: regtype, domain: domain) - queuedOrder.append(id) - } - - private func removeQueuedResolve(_ id: CmxIrohBonjourServiceID) { - guard queued.removeValue(forKey: id) != nil else { return } - queuedOrder.removeAll { $0 == id } - } - - private func drainQueuedResolves() { - while pending.count < maximumPendingResolves, !queuedOrder.isEmpty { - let id = queuedOrder.removeFirst() - guard let resolve = queued.removeValue(forKey: id) else { continue } - startResolveNow(id: id, regtype: resolve.regtype, domain: resolve.domain) - } - } - - private func handleResolve( - id: CmxIrohBonjourServiceID, - token: UUID, - errorCode: Int32, - interfaceIndex: UInt32, - hostTarget: String?, - port: UInt16, - txtRecord: Data? - ) { - guard pending[id]?.token == token else { return } - defer { stopResolve(id, matching: token) } - guard errorCode == kDNSServiceErr_NoError else { - publishError(errorCode) - if errorCode == kDNSServiceErr_PolicyDenied { stopOperations() } - return - } - guard interfaceIndex == id.interfaceIndex, - let hostTarget, - hostTarget.utf8.count <= 253, - let txtRecord, - txtRecord.count <= CmxIrohLANTXTRecord.maximumEncodedSize else { return } - publish(.resolved( - id, - CmxIrohBonjourResolvedService( - serviceName: id.serviceName, - hostTarget: hostTarget.lowercased(), - interfaceIndex: interfaceIndex, - port: port, - txtRecord: txtRecord - ) - )) - } - - private func expireResolve( - id: CmxIrohBonjourServiceID, - token: UUID - ) { - stopResolve(id, matching: token) - } - - private func stopResolve( - _ id: CmxIrohBonjourServiceID, - matching token: UUID? = nil - ) { - guard let resolve = pending[id], - token == nil || resolve.token == token else { return } - pending[id] = nil - resolve.deadlineTask.cancel() - resolve.operation.cancel() - drainQueuedResolves() - } - - private func stopOperations() { - browseToken = nil - browseIngress?.finish() - browseIngress = nil - browseEventTask?.cancel() - browseEventTask = nil - let currentBrowseOperation = browseOperation - browseOperation = nil - let resolves = Array(pending.values) - pending.removeAll(keepingCapacity: false) - queued.removeAll(keepingCapacity: false) - queuedOrder.removeAll(keepingCapacity: false) - for resolve in resolves { resolve.deadlineTask.cancel() } - for resolve in resolves { resolve.operation.cancel() } - currentBrowseOperation?.cancel() - } - - private func publishError(_ code: Int32) { - if code == kDNSServiceErr_PolicyDenied { - publish(.policyDenied) - } else { - publish(.failed(code)) - } - } - - private func publish(_ event: CmxIrohBonjourBrowserEvent) { - for observer in observers.values { observer.yield(event) } - } - - private func removeObserver(_ id: UUID) { - observers.removeValue(forKey: id) - if observers.isEmpty { stopOperations() } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourDNSService.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourDNSService.swift deleted file mode 100644 index 9360b9d0..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourDNSService.swift +++ /dev/null @@ -1,325 +0,0 @@ -@preconcurrency import Dispatch -import dnssd -import Foundation - -typealias CmxIrohBonjourBrowseHandler = @Sendable ( - DNSServiceFlags, - UInt32, - Int32, - String?, - String?, - String? -) -> Void - -typealias CmxIrohBonjourResolveHandler = @Sendable ( - Int32, - UInt32, - String?, - UInt16, - Data? -) async -> Void - -protocol CmxIrohBonjourOperation: Sendable { - func cancel() -} - -protocol CmxIrohBonjourDNSService: Sendable { - func startBrowse( - serviceType: String, - domain: String, - handler: @escaping CmxIrohBonjourBrowseHandler - ) throws -> any CmxIrohBonjourOperation - - func startResolve( - id: CmxIrohBonjourServiceID, - regtype: String, - domain: String, - handler: @escaping CmxIrohBonjourResolveHandler - ) throws -> any CmxIrohBonjourOperation -} - -protocol CmxIrohBonjourClock: Sendable { - func now() -> Date - func sleep(until deadline: Date) async throws -} - -struct CmxIrohSystemBonjourClock: CmxIrohBonjourClock { - func now() -> Date { Date() } - - func sleep(until deadline: Date) async throws { - let delay = deadline.timeIntervalSinceNow - guard delay > 0 else { return } - try await Task<Never, Never>.sleep(for: .seconds(delay)) - } -} - -struct CmxIrohBonjourDNSServiceError: Error, Sendable { - let code: Int32 -} - -private final class CmxIrohBonjourBrowseCallbackBox: @unchecked Sendable { - let handler: CmxIrohBonjourBrowseHandler - - init(handler: @escaping CmxIrohBonjourBrowseHandler) { - self.handler = handler - } -} - -struct CmxIrohBonjourRawBrowseEvent: Sendable { - enum Key: Hashable, Sendable { - case service(CmxIrohBonjourServiceID, added: Bool) - case error(Int32) - } - - let key: Key - let flags: DNSServiceFlags - let interfaceIndex: UInt32 - let errorCode: Int32 - let serviceName: String? - let regtype: String? - let domain: String? -} - -/// Validates and coalesces the synchronous DNS-SD callback before it reaches -/// Swift concurrency. One bounded stream consumer replaces one unbounded Task -/// per unauthenticated LAN record. -final class CmxIrohBonjourBrowseIngress: @unchecked Sendable { - private let lock = NSLock() - private let continuation: AsyncStream<CmxIrohBonjourRawBrowseEvent>.Continuation - private var enqueuedKeys: Set<CmxIrohBonjourRawBrowseEvent.Key> = [] - - init(continuation: AsyncStream<CmxIrohBonjourRawBrowseEvent>.Continuation) { - self.continuation = continuation - } - - func offer( - flags: DNSServiceFlags, - interfaceIndex: UInt32, - errorCode: Int32, - serviceName: String?, - regtype: String?, - domain: String? - ) { - let event: CmxIrohBonjourRawBrowseEvent - if errorCode != kDNSServiceErr_NoError { - event = CmxIrohBonjourRawBrowseEvent( - key: .error(errorCode), - flags: flags, - interfaceIndex: interfaceIndex, - errorCode: errorCode, - serviceName: nil, - regtype: nil, - domain: nil - ) - } else { - guard interfaceIndex != 0, - let serviceName, - CmxIrohLANRendezvousAliasGenerator.isCanonicalAlias(serviceName), - let regtype, - let domain, - regtype == "\(CmxIrohLANAdvertisement.serviceType).", - domain == CmxIrohLANAdvertisement.domain else { return } - let id = CmxIrohBonjourServiceID( - serviceName: serviceName, - interfaceIndex: interfaceIndex - ) - let added = flags & DNSServiceFlags(kDNSServiceFlagsAdd) != 0 - event = CmxIrohBonjourRawBrowseEvent( - key: .service(id, added: added), - flags: flags, - interfaceIndex: interfaceIndex, - errorCode: errorCode, - serviceName: serviceName, - regtype: regtype, - domain: domain - ) - } - - let shouldYield = lock.withLock { enqueuedKeys.insert(event.key).inserted } - guard shouldYield else { return } - if case .dropped = continuation.yield(event) { - consumed(event.key) - } - } - - func consumed(_ key: CmxIrohBonjourRawBrowseEvent.Key) { - lock.withLock { _ = enqueuedKeys.remove(key) } - } - - func finish() { - continuation.finish() - lock.withLock { enqueuedKeys.removeAll(keepingCapacity: false) } - } -} - -private final class CmxIrohBonjourResolveCallbackBox: @unchecked Sendable { - let handler: CmxIrohBonjourResolveHandler - - init(handler: @escaping CmxIrohBonjourResolveHandler) { - self.handler = handler - } -} - -private let cmxIrohBonjourBrowseCallback: DNSServiceBrowseReply = { - _, flags, interfaceIndex, errorCode, serviceName, regtype, replyDomain, context in - guard let context else { return } - let handler = Unmanaged<CmxIrohBonjourBrowseCallbackBox> - .fromOpaque(context) - .takeUnretainedValue() - .handler - let name = serviceName.map(String.init(cString:)) - let type = regtype.map(String.init(cString:)) - let domain = replyDomain.map(String.init(cString:)) - handler(flags, interfaceIndex, errorCode, name, type, domain) -} - -private let cmxIrohBonjourResolveCallback: DNSServiceResolveReply = { - _, _, interfaceIndex, errorCode, _, hostTarget, port, txtLength, txtRecord, context in - guard let context else { return } - let handler = Unmanaged<CmxIrohBonjourResolveCallbackBox> - .fromOpaque(context) - .takeUnretainedValue() - .handler - let data: Data? - if txtLength == 0 { - data = Data() - } else if let txtRecord { - data = Data(bytes: txtRecord, count: Int(txtLength)) - } else { - data = nil - } - let host = hostTarget.map(String.init(cString:)) - let hostPort = UInt16(bigEndian: port) - Task { - await handler(errorCode, interfaceIndex, host, hostPort, data) - } -} - -private final class CmxIrohBonjourSystemOperation: CmxIrohBonjourOperation, @unchecked Sendable { - private struct State { - let ref: DNSServiceRef - let context: UnsafeMutableRawPointer - } - - private let queue: DispatchQueue - private let lock = NSLock() - private let releaseContext: (UnsafeMutableRawPointer) -> Void - private var state: State? - - init( - ref: DNSServiceRef, - context: UnsafeMutableRawPointer, - queue: DispatchQueue, - releaseContext: @escaping (UnsafeMutableRawPointer) -> Void - ) { - state = State(ref: ref, context: context) - self.queue = queue - self.releaseContext = releaseContext - } - - func cancel() { - let current = lock.withLock { - defer { state = nil } - return state - } - guard let current else { return } - queue.sync { DNSServiceRefDeallocate(current.ref) } - releaseContext(current.context) - } -} - -final class CmxIrohBonjourSystemDNSService: CmxIrohBonjourDNSService, @unchecked Sendable { - private let queue: DispatchQueue - - init(queue: DispatchQueue) { - self.queue = queue - } - - func startBrowse( - serviceType: String, - domain: String, - handler: @escaping CmxIrohBonjourBrowseHandler - ) throws -> any CmxIrohBonjourOperation { - let callback = CmxIrohBonjourBrowseCallbackBox(handler: handler) - let context = Unmanaged.passRetained(callback).toOpaque() - var ref: DNSServiceRef? - let errorCode = DNSServiceBrowse( - &ref, - 0, - 0, - serviceType, - domain, - cmxIrohBonjourBrowseCallback, - context - ) - guard errorCode == kDNSServiceErr_NoError, let ref else { - Unmanaged<CmxIrohBonjourBrowseCallbackBox>.fromOpaque(context).release() - throw CmxIrohBonjourDNSServiceError( - code: errorCode == kDNSServiceErr_NoError - ? Int32(kDNSServiceErr_Unknown) - : errorCode - ) - } - let queueError = DNSServiceSetDispatchQueue(ref, queue) - guard queueError == kDNSServiceErr_NoError else { - DNSServiceRefDeallocate(ref) - Unmanaged<CmxIrohBonjourBrowseCallbackBox>.fromOpaque(context).release() - throw CmxIrohBonjourDNSServiceError(code: queueError) - } - return CmxIrohBonjourSystemOperation( - ref: ref, - context: context, - queue: queue, - releaseContext: { context in - Unmanaged<CmxIrohBonjourBrowseCallbackBox> - .fromOpaque(context) - .release() - } - ) - } - - func startResolve( - id: CmxIrohBonjourServiceID, - regtype: String, - domain: String, - handler: @escaping CmxIrohBonjourResolveHandler - ) throws -> any CmxIrohBonjourOperation { - let callback = CmxIrohBonjourResolveCallbackBox(handler: handler) - let context = Unmanaged.passRetained(callback).toOpaque() - var ref: DNSServiceRef? - let errorCode = DNSServiceResolve( - &ref, - 0, - id.interfaceIndex, - id.serviceName, - regtype, - domain, - cmxIrohBonjourResolveCallback, - context - ) - guard errorCode == kDNSServiceErr_NoError, let ref else { - Unmanaged<CmxIrohBonjourResolveCallbackBox>.fromOpaque(context).release() - throw CmxIrohBonjourDNSServiceError( - code: errorCode == kDNSServiceErr_NoError - ? Int32(kDNSServiceErr_Unknown) - : errorCode - ) - } - let queueError = DNSServiceSetDispatchQueue(ref, queue) - guard queueError == kDNSServiceErr_NoError else { - DNSServiceRefDeallocate(ref) - Unmanaged<CmxIrohBonjourResolveCallbackBox>.fromOpaque(context).release() - throw CmxIrohBonjourDNSServiceError(code: queueError) - } - return CmxIrohBonjourSystemOperation( - ref: ref, - context: context, - queue: queue, - releaseContext: { context in - Unmanaged<CmxIrohBonjourResolveCallbackBox> - .fromOpaque(context) - .release() - } - ) - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourPublisher.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourPublisher.swift deleted file mode 100644 index dc15efd4..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohSystemBonjourPublisher.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Darwin -@preconcurrency import Dispatch -import dnssd -import Foundation - -private final class CmxIrohBonjourRegisterCallbackBox: @unchecked Sendable { - let handler: @Sendable (Int32) -> Void - - init(handler: @escaping @Sendable (Int32) -> Void) { - self.handler = handler - } -} - -private let cmxIrohBonjourServiceRegisterCallback: DNSServiceRegisterReply = { - _, _, errorCode, _, _, _, context in - guard let context else { return } - Unmanaged<CmxIrohBonjourRegisterCallbackBox> - .fromOpaque(context) - .takeUnretainedValue() - .handler(errorCode) -} - -private let cmxIrohBonjourRecordRegisterCallback: DNSServiceRegisterRecordReply = { - _, _, _, errorCode, context in - guard let context else { return } - Unmanaged<CmxIrohBonjourRegisterCallbackBox> - .fromOpaque(context) - .takeUnretainedValue() - .handler(errorCode) -} - -/// Low-level DNS-SD publisher with an explicit opaque SRV target. -/// -/// It does not open a UDP listener and never asks Bonjour to substitute the -/// computer name. Each service is interface-scoped and has matching A/AAAA -/// records registered for its rotating opaque host target. -public actor CmxIrohSystemBonjourPublisher: CmxIrohBonjourPublishing { - private struct Registration { - let serviceRef: DNSServiceRef - let addressRef: DNSServiceRef - let callbackContexts: [UnsafeMutableRawPointer] - } - - private let queue = DispatchQueue(label: "dev.cmux.iroh.bonjour.publisher") - private var registrations: [Registration] = [] - private var observers: [ - UUID: AsyncStream<CmxIrohBonjourPublisherEvent>.Continuation - ] = [:] - - public init() {} - - public func events() -> AsyncStream<CmxIrohBonjourPublisherEvent> { - let id = UUID() - return AsyncStream(bufferingPolicy: .bufferingNewest(32)) { continuation in - observers[id] = continuation - continuation.onTermination = { [weak self] _ in - Task { await self?.removeObserver(id) } - } - } - } - - public func replace(with advertisements: [CmxIrohLANAdvertisement]) async throws { - guard advertisements.count <= CmxIrohLANAdvertisementBuilder.maximumInterfaceCount, - Set(advertisements.map(\.interfaceIndex)).count == advertisements.count else { - throw CmxIrohLANDiscoveryError.invalidAdvertisement - } - stopRegistrations() - do { - for advertisement in advertisements { - registrations.append(try register(advertisement)) - } - } catch { - stopRegistrations() - throw error - } - } - - public func stop() { - stopRegistrations() - for observer in observers.values { observer.finish() } - observers.removeAll(keepingCapacity: false) - } - - private func register(_ advertisement: CmxIrohLANAdvertisement) throws -> Registration { - guard advertisement.hostTarget == "h-\(advertisement.alias).local.", - advertisement.addresses.count <= CmxIrohLANTXTRecord.maximumAddressCount else { - throw CmxIrohLANDiscoveryError.invalidAdvertisement - } - var callbackContexts: [UnsafeMutableRawPointer] = [] - var addressRef: DNSServiceRef? - var serviceRef: DNSServiceRef? - - let addressCreateError = DNSServiceCreateConnection(&addressRef) - guard addressCreateError == kDNSServiceErr_NoError, - let addressRef else { - throw Self.error(addressCreateError) - } - let addressQueueError = DNSServiceSetDispatchQueue(addressRef, queue) - guard addressQueueError == kDNSServiceErr_NoError else { - DNSServiceRefDeallocate(addressRef) - throw Self.error(addressQueueError) - } - - do { - var registeredIPAddresses: Set<String> = [] - for address in advertisement.addresses { - // Multiple Iroh sockets may share one IP with different ports. - // DNS A/AAAA records describe only the host, so register each - // canonical IP once while retaining every socket in TXT. - guard registeredIPAddresses.insert(address.ipAddress).inserted else { - continue - } - let callback = CmxIrohBonjourRegisterCallbackBox { [weak self] errorCode in - guard errorCode != kDNSServiceErr_NoError else { return } - Task { await self?.handle(errorCode) } - } - let context = Unmanaged.passRetained(callback).toOpaque() - callbackContexts.append(context) - let record = try Self.addressRecord(address) - var recordRef: DNSRecordRef? - let registerError = record.data.withUnsafeBytes { bytes in - DNSServiceRegisterRecord( - addressRef, - &recordRef, - DNSServiceFlags(kDNSServiceFlagsUnique), - advertisement.interfaceIndex, - advertisement.hostTarget, - record.type, - UInt16(kDNSServiceClass_IN), - UInt16(bytes.count), - bytes.baseAddress, - 60, - cmxIrohBonjourRecordRegisterCallback, - context - ) - } - guard registerError == kDNSServiceErr_NoError else { - throw Self.error(registerError) - } - } - - let serviceID = CmxIrohBonjourServiceID( - serviceName: advertisement.alias, - interfaceIndex: advertisement.interfaceIndex - ) - let callback = CmxIrohBonjourRegisterCallbackBox { [weak self] errorCode in - Task { await self?.handle(errorCode, serviceID: serviceID) } - } - let context = Unmanaged.passRetained(callback).toOpaque() - callbackContexts.append(context) - let registerError = advertisement.txtRecord.withUnsafeBytes { bytes in - DNSServiceRegister( - &serviceRef, - DNSServiceFlags(kDNSServiceFlagsNoAutoRename), - advertisement.interfaceIndex, - advertisement.alias, - CmxIrohLANAdvertisement.serviceType, - CmxIrohLANAdvertisement.domain, - advertisement.hostTarget, - advertisement.port.bigEndian, - UInt16(bytes.count), - bytes.baseAddress, - cmxIrohBonjourServiceRegisterCallback, - context - ) - } - guard registerError == kDNSServiceErr_NoError, - let serviceRef else { - throw Self.error(registerError) - } - let serviceQueueError = DNSServiceSetDispatchQueue(serviceRef, queue) - guard serviceQueueError == kDNSServiceErr_NoError else { - throw Self.error(serviceQueueError) - } - return Registration( - serviceRef: serviceRef, - addressRef: addressRef, - callbackContexts: callbackContexts - ) - } catch { - queue.sync { DNSServiceRefDeallocate(addressRef) } - if let serviceRef { DNSServiceRefDeallocate(serviceRef) } - Self.release(callbackContexts) - throw error - } - } - - private func handle( - _ errorCode: Int32, - serviceID: CmxIrohBonjourServiceID? = nil - ) { - if errorCode == kDNSServiceErr_NoError, let serviceID { - publish(.registered(serviceID)) - } else if errorCode == kDNSServiceErr_PolicyDenied { - publish(.policyDenied) - stopRegistrations() - } else if errorCode != kDNSServiceErr_NoError { - publish(.failed(errorCode)) - } - } - - private func stopRegistrations() { - let previous = registrations - registrations.removeAll(keepingCapacity: false) - guard !previous.isEmpty else { return } - queue.sync { - for registration in previous { - DNSServiceRefDeallocate(registration.serviceRef) - DNSServiceRefDeallocate(registration.addressRef) - } - } - for registration in previous { - Self.release(registration.callbackContexts) - } - } - - private func publish(_ event: CmxIrohBonjourPublisherEvent) { - for observer in observers.values { observer.yield(event) } - } - - private func removeObserver(_ id: UUID) { - observers.removeValue(forKey: id) - } - - private static func addressRecord( - _ address: CmxIrohLANSocketAddress - ) throws -> (type: UInt16, data: Data) { - switch address.family { - case .ipv4: - var value = in_addr() - guard address.ipAddress.withCString({ inet_pton(AF_INET, $0, &value) }) == 1 else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - return (UInt16(kDNSServiceType_A), Data(bytes: &value, count: MemoryLayout.size(ofValue: value))) - case .ipv6: - var value = in6_addr() - guard address.ipAddress.withCString({ inet_pton(AF_INET6, $0, &value) }) == 1 else { - throw CmxIrohLANDiscoveryError.invalidSocketAddress - } - return (UInt16(kDNSServiceType_AAAA), Data(bytes: &value, count: MemoryLayout.size(ofValue: value))) - } - } - - private static func error(_ code: Int32) -> CmxIrohLANDiscoveryError { - code == kDNSServiceErr_PolicyDenied ? .policyDenied : .serviceFailure(code) - } - - private static func release(_ contexts: [UnsafeMutableRawPointer]) { - for context in contexts { - Unmanaged<CmxIrohBonjourRegisterCallbackBox>.fromOpaque(context).release() - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift deleted file mode 100644 index 136963a6..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift +++ /dev/null @@ -1,15 +0,0 @@ -/// Orders mobile-host transport startup so the required TCP listener is -/// available before optional Iroh policy and credential work is scheduled. -/// -/// `scheduleIroh` must enqueue asynchronous activation and return immediately. -/// Keeping that boundary synchronous makes it impossible for a relay-policy or -/// Keychain suspension to delay the existing TCP listener. -public enum CmxIrohTCPFirstActivation { - public static func start( - startTCP: () -> Void, - scheduleIroh: () -> Void - ) { - startTCP() - scheduleIroh() - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelope.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelope.swift deleted file mode 100644 index f96d65a5..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelope.swift +++ /dev/null @@ -1,60 +0,0 @@ -public import Foundation - -/// One bounded, sequence-aware terminal-output frame on an Iroh application lane. -/// -/// The first frame on every lane is ``Kind/replay``. Its retained-base and -/// current sequences let the receiver prove that the requested cursor was -/// covered by the Mac's bounded history. Later ``Kind/chunk`` frames retain the -/// same explicit sequence boundaries, so QUIC receive chunking cannot hide a -/// duplicate or gap. -public struct CmxIrohTerminalOutputEnvelope: Equatable, Sendable { - public enum Kind: UInt8, Equatable, Sendable { - case replay = 1 - case chunk = 2 - } - - public enum ValidationError: Error, Equatable, Sendable { - case invalidSequenceRange - case payloadLengthMismatch(expected: UInt64, actual: Int) - case payloadTooLarge(actual: Int, maximum: Int) - } - - public static let maximumPayloadByteCount = 256 * 1_024 - - public let kind: Kind - public let retainedBaseSequence: UInt64 - public let sequence: UInt64 - public let currentSequence: UInt64 - public let payload: Data - - public init( - kind: Kind, - retainedBaseSequence: UInt64, - sequence: UInt64, - currentSequence: UInt64, - payload: Data - ) throws { - guard retainedBaseSequence <= sequence, - sequence <= currentSequence else { - throw ValidationError.invalidSequenceRange - } - let expectedPayloadLength = currentSequence - sequence - guard expectedPayloadLength == UInt64(payload.count) else { - throw ValidationError.payloadLengthMismatch( - expected: expectedPayloadLength, - actual: payload.count - ) - } - guard payload.count <= Self.maximumPayloadByteCount else { - throw ValidationError.payloadTooLarge( - actual: payload.count, - maximum: Self.maximumPayloadByteCount - ) - } - self.kind = kind - self.retainedBaseSequence = retainedBaseSequence - self.sequence = sequence - self.currentSequence = currentSequence - self.payload = payload - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelopeCodec.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelopeCodec.swift deleted file mode 100644 index 23b68f98..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelopeCodec.swift +++ /dev/null @@ -1,116 +0,0 @@ -public import Foundation - -/// Binary framing for sequence-aware terminal-output envelopes. -public struct CmxIrohTerminalOutputEnvelopeCodec: Sendable { - public enum DecodeError: Error, Equatable, Sendable { - case incompleteFrame - case invalidMagic - case unsupportedVersion(UInt8) - case invalidKind(UInt8) - case invalidReservedBits(UInt16) - case payloadTooLarge(actual: Int, maximum: Int) - } - - public static let headerByteCount = 36 - - private static let magic = Data("CMXT".utf8) - private static let version: UInt8 = 1 - - public init() {} - - public func encode(_ envelope: CmxIrohTerminalOutputEnvelope) -> Data { - var frame = Self.magic - frame.append(Self.version) - frame.append(envelope.kind.rawValue) - Self.append(UInt16.zero, to: &frame) - Self.append(envelope.retainedBaseSequence, to: &frame) - Self.append(envelope.sequence, to: &frame) - Self.append(envelope.currentSequence, to: &frame) - Self.append(UInt32(envelope.payload.count), to: &frame) - frame.append(envelope.payload) - return frame - } - - public func decodePrefix(_ data: Data) throws -> CmxIrohTerminalOutputEnvelope { - guard data.count >= Self.headerByteCount else { - throw DecodeError.incompleteFrame - } - var offset = 0 - guard Self.readData(byteCount: Self.magic.count, from: data, offset: &offset) == Self.magic else { - throw DecodeError.invalidMagic - } - let version = Self.readUInt8(from: data, offset: &offset) - guard version == Self.version else { - throw DecodeError.unsupportedVersion(version) - } - let rawKind = Self.readUInt8(from: data, offset: &offset) - guard let kind = CmxIrohTerminalOutputEnvelope.Kind(rawValue: rawKind) else { - throw DecodeError.invalidKind(rawKind) - } - let reserved = Self.readUInt16(from: data, offset: &offset) - guard reserved == 0 else { - throw DecodeError.invalidReservedBits(reserved) - } - let retainedBaseSequence = Self.readUInt64(from: data, offset: &offset) - let sequence = Self.readUInt64(from: data, offset: &offset) - let currentSequence = Self.readUInt64(from: data, offset: &offset) - let payloadByteCount = Int(Self.readUInt32(from: data, offset: &offset)) - guard payloadByteCount <= CmxIrohTerminalOutputEnvelope.maximumPayloadByteCount else { - throw DecodeError.payloadTooLarge( - actual: payloadByteCount, - maximum: CmxIrohTerminalOutputEnvelope.maximumPayloadByteCount - ) - } - guard data.count >= Self.headerByteCount + payloadByteCount else { - throw DecodeError.incompleteFrame - } - let payload = Self.readData(byteCount: payloadByteCount, from: data, offset: &offset) - return try CmxIrohTerminalOutputEnvelope( - kind: kind, - retainedBaseSequence: retainedBaseSequence, - sequence: sequence, - currentSequence: currentSequence, - payload: payload - ) - } - - private static func append<T: FixedWidthInteger>(_ value: T, to data: inout Data) { - var bigEndian = value.bigEndian - withUnsafeBytes(of: &bigEndian) { data.append(contentsOf: $0) } - } - - private static func readUInt8(from data: Data, offset: inout Int) -> UInt8 { - let value = data[data.index(data.startIndex, offsetBy: offset)] - offset += 1 - return value - } - - private static func readUInt16(from data: Data, offset: inout Int) -> UInt16 { - readInteger(byteCount: 2, from: data, offset: &offset) - } - - private static func readUInt32(from data: Data, offset: inout Int) -> UInt32 { - readInteger(byteCount: 4, from: data, offset: &offset) - } - - private static func readUInt64(from data: Data, offset: inout Int) -> UInt64 { - readInteger(byteCount: 8, from: data, offset: &offset) - } - - private static func readInteger<T: FixedWidthInteger>( - byteCount: Int, - from data: Data, - offset: inout Int - ) -> T { - readData(byteCount: byteCount, from: data, offset: &offset).reduce(T.zero) { - ($0 << 8) | T($1) - } - } - - private static func readData(byteCount: Int, from data: Data, offset: inout Int) -> Data { - let start = data.index(data.startIndex, offsetBy: offset) - let end = data.index(start, offsetBy: byteCount) - offset += byteCount - return data[start ..< end] - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelopeDecoder.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelopeDecoder.swift deleted file mode 100644 index 029b0f7b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTerminalOutputEnvelopeDecoder.swift +++ /dev/null @@ -1,31 +0,0 @@ -public import Foundation - -/// Incremental decoder for arbitrarily chunked Iroh terminal-output bytes. -public struct CmxIrohTerminalOutputEnvelopeDecoder: Sendable { - private var buffer = Data() - private let codec: CmxIrohTerminalOutputEnvelopeCodec - - public init(codec: CmxIrohTerminalOutputEnvelopeCodec = CmxIrohTerminalOutputEnvelopeCodec()) { - self.codec = codec - } - - public var hasBufferedBytes: Bool { !buffer.isEmpty } - - public mutating func append(_ data: Data) throws -> [CmxIrohTerminalOutputEnvelope] { - guard !data.isEmpty else { return [] } - buffer.append(data) - var envelopes: [CmxIrohTerminalOutputEnvelope] = [] - while buffer.count >= CmxIrohTerminalOutputEnvelopeCodec.headerByteCount { - do { - let envelope = try codec.decodePrefix(buffer) - let frameByteCount = CmxIrohTerminalOutputEnvelopeCodec.headerByteCount - + envelope.payload.count - buffer.removeFirst(frameByteCount) - envelopes.append(envelope) - } catch CmxIrohTerminalOutputEnvelopeCodec.DecodeError.incompleteFrame { - break - } - } - return envelopes - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift deleted file mode 100644 index ca757a05..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +++ /dev/null @@ -1,497 +0,0 @@ -public import CMUXMobileCore -public import Foundation - -/// Supplies the short-lived Stack credentials required by native API calls. -public struct CmxIrohBrokerTokenSource: Sendable { - public let accessToken: @Sendable () async -> String? - public let refreshToken: @Sendable () async -> String? - - public init( - accessToken: @escaping @Sendable () async -> String?, - refreshToken: @escaping @Sendable () async -> String? - ) { - self.accessToken = accessToken - self.refreshToken = refreshToken - } -} - -/// Injectable URL-loading boundary used by the trust broker client. -protocol CmxIrohHTTPTransport: Sendable { - func data(for request: URLRequest) async throws -> (Data, URLResponse) -} - -/// Production URLSession implementation of ``CmxIrohHTTPTransport``. -struct CmxIrohURLSessionTransport: CmxIrohHTTPTransport { - private let session: CmxCredentialedHTTPSession - - init(configuration: sending URLSessionConfiguration = .ephemeral) { - session = CmxCredentialedHTTPSession(configuration: configuration) - } - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - try await session.data(for: request) - } -} - -/// Authenticated client for endpoint registration, discovery, grants, and relay tokens. -public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing { - private struct BindingRequest: Encodable { let bindingId: String } - private struct EndpointRequest: Encodable { let endpointId: String } - private struct RelayAccessCredential: Decodable { - let relayUrl: String - let token: String - let expiresAt: Int64 - let refreshAfter: Int64 - let ttlSeconds: Int64 - } - private struct RelayAccessResponse: Decodable { - let token: String? - let expiresAt: Int64? - let ttlSeconds: Int64? - let relays: [String]? - let endpointId: String? - let relayCredentials: [RelayAccessCredential]? - let policy: String? - let preference: CmxIrohAccountRelayConfiguration? - let preferenceRevision: Int64? - } - private struct RelayTokenHeader: Decodable { - let alg: String - let typ: String - } - private struct RelayTokenClaims: Decodable { - let issuer: String - let audience: String - let expiresAt: Int64 - let endpointID: String - - private enum CodingKeys: String, CodingKey { - case issuer = "iss" - case audience = "aud" - case expiresAt = "exp" - case endpointID = "endpoint_id" - } - } - private struct PairGrantRequest: Encodable { - let initiatorBindingId: String - let acceptorBindingId: String - } - private struct RevokeResponse: Decodable { - let revoked: Bool - let lanRendezvousRotated: Bool - - private enum CodingKeys: String, CodingKey { - case revoked - case lanRendezvousRotated = "lan_rendezvous_rotated" - } - } - private struct BrokerError: Decodable { let error: String } - - private let baseURL: URL - private let tokenSource: CmxIrohBrokerTokenSource - private let transport: any CmxIrohHTTPTransport - private let requestTimeout: TimeInterval - - /// Creates a client that rejects cleartext non-loopback API origins. - public init( - baseURL: URL, - tokenSource: CmxIrohBrokerTokenSource, - requestTimeout: TimeInterval = 10 - ) throws { - try self.init( - baseURL: baseURL, - tokenSource: tokenSource, - transport: CmxIrohURLSessionTransport(), - requestTimeout: requestTimeout - ) - } - - /// Creates a client with an injected HTTP transport for isolation and testing. - init( - baseURL: URL, - tokenSource: CmxIrohBrokerTokenSource, - transport: any CmxIrohHTTPTransport, - requestTimeout: TimeInterval = 10 - ) throws { - guard Self.isAllowedBaseURL(baseURL), requestTimeout > 0 else { - throw CmxIrohTrustBrokerClientError.invalidBaseURL - } - self.baseURL = baseURL - self.tokenSource = tokenSource - self.transport = transport - self.requestTimeout = requestTimeout - } - - public func issueChallenge( - _ request: CmxIrohChallengeRequest - ) async throws -> CmxIrohChallengeResponse { - try await send(path: "api/devices/iroh/challenge", method: "POST", body: request) - } - - public func register( - _ request: CmxIrohRegisterRequest - ) async throws -> CmxIrohRegistrationResponse { - try await send(path: "api/devices/iroh/register", method: "POST", body: request) - } - - /// Runs the challenge and signed registration legs without regenerating payload bytes. - public func register( - prepared: CmxIrohPreparedRegistration, - signer: CmxIrohRegistrationSigner - ) async throws -> CmxIrohRegistrationResponse { - let challenge = try await issueChallenge(prepared.challengeRequest) - let request = try signer.sign(prepared: prepared, challenge: challenge) - return try await register(request) - } - - public func discover() async throws -> CmxIrohDiscoveryResponse { - try await sendWithoutBody(path: "api/devices/iroh", method: "GET") - } - - public func issuePairGrant( - initiatorBindingID: String, - acceptorBindingID: String - ) async throws -> CmxIrohPairGrantResponse { - try await send( - path: "api/devices/iroh/pair-grants", - method: "POST", - body: PairGrantRequest( - initiatorBindingId: initiatorBindingID, - acceptorBindingId: acceptorBindingID - ) - ) - } - - public func issueEndpointAttestation( - bindingID: String - ) async throws -> CmxIrohEndpointAttestationResponse { - try await send( - path: "api/devices/iroh/endpoint-attestations", - method: "POST", - body: BindingRequest(bindingId: bindingID) - ) - } - - public func issueRelayToken( - bindingID _: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - let response: RelayAccessResponse = try await send( - path: "api/relay/token", - method: "POST", - body: EndpointRequest(endpointId: endpointID.endpointID) - ) - return try Self.relayTokenResponse(response, endpointID: endpointID) - } - - /// Issues a managed credential together with signed, server-driven relay policy. - public func issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - let response: RelayAccessResponse = try await send( - path: "api/relay/token", - method: "POST", - body: EndpointRequest(endpointId: endpointID.endpointID) - ) - guard let policy = response.policy, - let preference = response.preference, - let preferenceRevision = response.preferenceRevision else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let policyResponse: CmxIrohRelayPolicyResponse - do { - policyResponse = try CmxIrohRelayPolicyResponse( - policy: policy, - preference: preference, - preferenceRevision: preferenceRevision - ) - } catch { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayToken: CmxIrohRelayTokenResponse? - if response.relayCredentials == nil, response.token == nil { - relayToken = nil - } else { - relayToken = try Self.relayTokenResponse(response, endpointID: endpointID) - } - return CmxIrohRelayBootstrapResponse( - relayToken: relayToken, - relayPolicy: policyResponse - ) - } - - /// Fetches the current account relay preference. - public func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { - try await sendWithoutBody(path: "api/relay/preferences", method: "GET") - } - - /// Replaces the current account relay preference using optimistic concurrency. - public func updateRelayPreference( - _ request: CmxIrohRelayPreferenceUpdateRequest - ) async throws -> CmxIrohRelayPreferenceResponse { - try await send(path: "api/relay/preferences", method: "PUT", body: request) - } - - public func revoke(bindingID: String) async throws { - let response: RevokeResponse = try await send( - path: "api/devices/iroh", - method: "DELETE", - body: BindingRequest(bindingId: bindingID) - ) - guard response.revoked, response.lanRendezvousRotated else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - } - - private func send<Response: Decodable, Body: Encodable>( - path: String, - method: String, - body: Body - ) async throws -> Response { - let encoded = try JSONEncoder().encode(body) - return try await perform(path: path, method: method, body: encoded) - } - - private func sendWithoutBody<Response: Decodable>( - path: String, - method: String - ) async throws -> Response { - try await perform(path: path, method: method, body: nil) - } - - private func perform<Response: Decodable>( - path: String, - method: String, - body: Data? - ) async throws -> Response { - let accessToken = await tokenSource.accessToken() - let refreshToken = await tokenSource.refreshToken() - guard let accessToken, let refreshToken else { - throw CmxIrohTrustBrokerClientError.missingAuthentication - } - guard Self.isSafeHeaderValue(accessToken), Self.isSafeHeaderValue(refreshToken) else { - throw CmxIrohTrustBrokerClientError.invalidAuthentication - } - let url = baseURL.appendingPathComponent(path) - var request = URLRequest(url: url) - request.httpMethod = method - request.timeoutInterval = requestTimeout - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let body { - request.httpBody = body - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - } - - let data: Data - let response: URLResponse - do { - (data, response) = try await transport.data(for: request) - } catch let error as URLError where Self.isConnectivityFailure(error.code) { - throw CmxIrohTrustBrokerClientError.connectivity - } - guard let http = response as? HTTPURLResponse else { - throw CmxIrohTrustBrokerClientError.nonHTTPResponse - } - guard http.url == url else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - guard (200 ... 299).contains(http.statusCode) else { - let code = try? JSONDecoder().decode(BrokerError.self, from: data).error - if http.statusCode == 429, - let retryAfterSeconds = Self.retryAfterSeconds( - http.value(forHTTPHeaderField: "Retry-After") - ) { - throw CmxIrohTrustBrokerClientError.rateLimited( - code: code, - retryAfterSeconds: retryAfterSeconds - ) - } - throw CmxIrohTrustBrokerClientError.rejected( - statusCode: http.statusCode, - code: code - ) - } - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom(CmxIrohISO8601Date.decode) - return try decoder.decode(Response.self, from: data) - } catch { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - } - - private static func isAllowedBaseURL(_ url: URL) -> Bool { - guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let scheme = components.scheme?.lowercased(), - let host = components.host?.lowercased(), - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil else { - return false - } - if scheme == "https" { return true } - return scheme == "http" && ["127.0.0.1", "::1", "localhost"].contains(host) - } - - private static func isSafeHeaderValue(_ value: String) -> Bool { - (1 ... 16 * 1_024).contains(value.utf8.count) - && !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f }) - } - - private static func retryAfterSeconds(_ value: String?) -> Int? { - guard let value, - !value.isEmpty, - value.utf8.allSatisfy({ (48 ... 57).contains($0) }), - let seconds = Int(value), - (1 ... 3_600).contains(seconds), - String(seconds) == value else { - return nil - } - return seconds - } - - private static func relayTokenResponse( - _ response: RelayAccessResponse, - endpointID: CmxIrohPeerIdentity - ) throws -> CmxIrohRelayTokenResponse { - if let credentials = response.relayCredentials { - guard response.endpointId == endpointID.endpointID, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - credentials.count - ) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayCredentials = try credentials.map { credential in - guard (30 ... 24 * 60 * 60).contains(credential.ttlSeconds), - credential.expiresAt > credential.refreshAfter, - credential.refreshAfter - >= credential.expiresAt - credential.ttlSeconds, - (1 ... 8 * 1_024).contains(credential.token.utf8.count) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohManagedRelayCredential( - relayURL: try canonicalRelayOrigin(credential.relayUrl), - token: credential.token, - expiresAt: iso8601(epochSeconds: credential.expiresAt), - refreshAfter: iso8601(epochSeconds: credential.refreshAfter) - ) - } - guard Set(relayCredentials.map(\.relayURL)).count - == relayCredentials.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohRelayTokenResponse(credentials: relayCredentials) - } - - guard let token = response.token, - let expiresAtSeconds = response.expiresAt, - let ttlSeconds = response.ttlSeconds, - let relays = response.relays, - ttlSeconds == 300, - expiresAtSeconds > ttlSeconds, - (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount).contains( - relays.count - ), - validRelayToken( - token, - expiresAt: expiresAtSeconds, - endpointID: endpointID - ) else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let relayFleet = try relays.map(canonicalRelayOrigin) - guard Set(relayFleet).count == relayFleet.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - let refreshLead = min(60, ttlSeconds / 2) - return CmxIrohRelayTokenResponse( - token: token, - expiresAt: iso8601(epochSeconds: expiresAtSeconds), - refreshAfter: iso8601(epochSeconds: expiresAtSeconds - refreshLead), - relayFleet: relayFleet - ) - } - - private static func iso8601(epochSeconds: Int64) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string( - from: Date(timeIntervalSince1970: TimeInterval(epochSeconds)) - ) - } - - private static func validRelayToken( - _ token: String, - expiresAt: Int64, - endpointID: CmxIrohPeerIdentity - ) -> Bool { - guard (1 ... 8 * 1_024).contains(token.utf8.count) else { return false } - let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count == 3, - let headerData = base64URLData(segments[0]), - let claimsData = base64URLData(segments[1]), - let header = try? JSONDecoder().decode(RelayTokenHeader.self, from: headerData), - let claims = try? JSONDecoder().decode(RelayTokenClaims.self, from: claimsData) else { - return false - } - return header.alg == "EdDSA" - && header.typ == "JWT" - && claims.issuer == "cmux" - && claims.audience == "cmux-relay" - && claims.expiresAt == expiresAt - && claims.endpointID == endpointID.endpointID - } - - private static func base64URLData(_ value: Substring) -> Data? { - var encoded = String(value) - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let remainder = encoded.utf8.count % 4 - if remainder != 0 { - encoded.append(String(repeating: "=", count: 4 - remainder)) - } - return Data(base64Encoded: encoded) - } - - private static func canonicalRelayOrigin(_ value: String) throws -> String { - guard var components = URLComponents(string: value), - components.scheme == "https", - let host = components.host, - host == host.lowercased(), - !host.isEmpty, - components.port == nil, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil, - components.path.isEmpty || components.path == "/" else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - components.path = "/" - guard let canonical = components.string else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return canonical - } - - private static func isConnectivityFailure(_ code: URLError.Code) -> Bool { - switch code { - case .timedOut, - .cannotFindHost, - .cannotConnectToHost, - .networkConnectionLost, - .dnsLookupFailed, - .notConnectedToInternet, - .internationalRoamingOff, - .callIsActive, - .dataNotAllowed, - .cannotLoadFromNetwork: - true - default: - false - } - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift deleted file mode 100644 index cfbb8a9b..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClientError.swift +++ /dev/null @@ -1,68 +0,0 @@ -public import CMUXMobileCore - -/// Failures at the authenticated HTTP trust-broker boundary. -public enum CmxIrohTrustBrokerClientError: - CmxRetryAfterProviding, - Equatable, - Sendable -{ - /// The authenticated broker could not be reached through the current network. - case connectivity - case invalidBaseURL - case missingAuthentication - case invalidAuthentication - case nonHTTPResponse - /// The broker rejected a request and supplied a bounded retry floor. - case rateLimited(code: String?, retryAfterSeconds: Int) - case rejected(statusCode: Int, code: String?) - case invalidResponse - - static func preservesVerifiedPolicyDuringRefresh(_ error: any Error) -> Bool { - guard let brokerError = error as? Self else { return false } - switch brokerError { - case .connectivity: - return true - case .rateLimited: - return true - case let .rejected(statusCode, _): - return statusCode == 408 - || statusCode == 425 - || statusCode == 429 - || (500...599).contains(statusCode) - case .invalidBaseURL, - .missingAuthentication, - .invalidAuthentication, - .nonHTTPResponse, - .invalidResponse: - return false - } - } - - /// Accepts only failures that are safe to retry before any binding is trusted. - static func retriesInitialActivation(_ error: any Error) -> Bool { - guard let brokerError = error as? Self else { return false } - switch brokerError { - case .connectivity, .rateLimited: - return true - case let .rejected(statusCode, _): - // A server failure cannot establish trust, so retrying the request - // is safe while the lifecycle-owned start task remains current. - return statusCode == 408 - || statusCode == 425 - || statusCode == 429 - || (500...599).contains(statusCode) - case .invalidBaseURL, - .missingAuthentication, - .invalidAuthentication, - .nonHTTPResponse, - .invalidResponse: - return false - } - } - - /// The validated server retry floor, when present. - public var retryAfterSeconds: Int? { - guard case let .rateLimited(_, retryAfterSeconds) = self else { return nil } - return retryAfterSeconds - } -} diff --git a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohUserDefaultsInstallStateStore.swift b/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohUserDefaultsInstallStateStore.swift deleted file mode 100644 index 0f05f808..00000000 --- a/vendor/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohUserDefaultsInstallStateStore.swift +++ /dev/null @@ -1,25 +0,0 @@ -public import Foundation - -/// `UserDefaults` installation marker storage for production composition. -public final class CmxIrohUserDefaultsInstallStateStore: CmxIrohInstallStateStoring, @unchecked Sendable { - private let defaults: UserDefaults - - /// Creates a state store. - /// - /// - Parameter defaults: The app-local defaults domain. - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - public func string(forKey key: String) -> String? { - defaults.string(forKey: key) - } - - public func set(_ value: String?, forKey key: String) { - if let value { - defaults.set(value, forKey: key) - } else { - defaults.removeObject(forKey: key) - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift deleted file mode 100644 index 08204755..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestFixture.swift +++ /dev/null @@ -1,146 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -@testable import CmuxIrohTransport - -struct ClientRuntimeTestFixture { - static let relayURLs = [ - "https://aps1-1.relay.lawrence.cmux.iroh.link/", - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ] - - let identity: CmxIrohIdentityMaterial - let endpointID: CmxIrohPeerIdentity - let binding: CmxIrohBrokerBinding - let discovery: CmxIrohDiscoveryResponse - let configuration: CmxIrohClientRuntimeConfiguration - let now = Date(timeIntervalSince1970: 1_783_686_000) - - init() throws { - let secret = Data(repeating: 0x41, count: 32) - identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: secret), - generation: 3 - ) - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: secret) - endpointID = try CmxIrohPeerIdentity( - endpointID: privateKey.publicKey.rawRepresentation - .map { String(format: "%02x", $0) } - .joined() - ) - binding = try Self.binding(endpointID: endpointID.endpointID) - discovery = try Self.discovery(binding: binding) - configuration = CmxIrohClientRuntimeConfiguration( - accountID: "account-a", - deviceID: binding.deviceID, - appInstanceID: binding.appInstanceID, - tag: binding.tag, - displayName: binding.displayName, - identity: identity, - capabilities: binding.capabilities, - managedRelayURLs: Set(Self.relayURLs) - ) - } - - func relayResponse() -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: Self.relayURLs - ) - } - - func pendingRevocations() -> CmxIrohPendingRevocationOutbox { - CmxIrohPendingRevocationOutbox( - secureStore: TestSecureCredentialStore() - ) - } - - static func binding( - endpointID: String, - bindingID: String = "123e4567-e89b-42d3-a456-426614174020", - deviceID: String = "123e4567-e89b-42d3-a456-426614174021", - appInstanceID: String = "123e4567-e89b-42d3-a456-426614174022" - ) throws -> CmxIrohBrokerBinding { - try JSONDecoder().decode( - CmxIrohBrokerBinding.self, - from: bindingJSON( - endpointID: endpointID, - bindingID: bindingID, - deviceID: deviceID, - appInstanceID: appInstanceID - ) - ) - } - - static func discovery( - binding: CmxIrohBrokerBinding, - overrideAppInstanceID: String? = nil, - relayURLs: [String] = relayURLs - ) throws -> CmxIrohDiscoveryResponse { - let bindingObject = try JSONSerialization.jsonObject( - with: bindingJSON( - endpointID: binding.endpointID.endpointID, - bindingID: binding.bindingID, - deviceID: binding.deviceID, - appInstanceID: overrideAppInstanceID ?? binding.appInstanceID - ) - ) - let object: [String: Any] = [ - "route_contract_version": 1, - "bindings": [bindingObject], - "relay_fleet": relayURLs, - "lan_rendezvous": [ - "generation": 1, - "key": Data(repeating: 0, count: 32).clientRuntimeBase64URL, - ], - "grant_verification_keys": [ - "version": 1, - "current_kid": "test-key", - "keys": [[ - "kid": "test-key", - "alg": "EdDSA", - "spki_der_base64": "AA==", - ]], - ], - ] - return try JSONDecoder().decode( - CmxIrohDiscoveryResponse.self, - from: JSONSerialization.data(withJSONObject: object) - ) - } - - private static func bindingJSON( - endpointID: String, - bindingID: String, - deviceID: String, - appInstanceID: String - ) throws -> Data { - try JSONSerialization.data(withJSONObject: [ - "binding_id": bindingID, - "device_id": deviceID, - "app_instance_id": appInstanceID, - "tag": "cmux-ios-v0", - "platform": "ios", - "display_name": "Test iPhone", - "endpoint_id": endpointID, - "identity_generation": 3, - "pairing_enabled": false, - "capabilities": ["mobile-rpc-v1", "multistream-v1"], - "path_hints": [], - "last_seen_at": "2026-07-10T12:00:00.000Z", - ]) - } -} - -private extension Data { - var clientRuntimeBase64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestRecorder.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestRecorder.swift deleted file mode 100644 index 5a5bbf35..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ClientRuntimeTestRecorder.swift +++ /dev/null @@ -1,53 +0,0 @@ -@testable import CmuxIrohTransport - -actor ClientRuntimeTestRecorder { - private struct RelayWaiter { - let target: Int - let continuation: CheckedContinuation<Void, Never> - } - - private var bindingCount = 0 - private var relayCount = 0 - private var localWipeEndpointWasClosed: [Bool] = [] - private var cachedBindingDeviceIDs: [[String]] = [] - private var policyInvalidationCount = 0 - private var relayWaiters: [RelayWaiter] = [] - - func recordBinding() { - bindingCount += 1 - } - - func recordRelay() { - relayCount += 1 - let ready = relayWaiters.filter { relayCount >= $0.target } - relayWaiters.removeAll { relayCount >= $0.target } - for waiter in ready { - waiter.continuation.resume() - } - } - - func waitForRelayCount(_ target: Int) async { - guard relayCount < target else { return } - await withCheckedContinuation { continuation in - relayWaiters.append(RelayWaiter(target: target, continuation: continuation)) - } - } - - func recordLocalWipe(endpointWasClosed: Bool) { - localWipeEndpointWasClosed.append(endpointWasClosed) - } - - func recordCachedBindings(_ bindings: [CmxIrohBrokerBinding]) { - cachedBindingDeviceIDs.append(bindings.map(\.deviceID)) - } - - func recordPolicyInvalidation() { - policyInvalidationCount += 1 - } - - func observedBindingCount() -> Int { bindingCount } - func observedRelayCount() -> Int { relayCount } - func observedLocalWipes() -> [Bool] { localWipeEndpointWasClosed } - func observedCachedBindingDeviceIDs() -> [[String]] { cachedBindingDeviceIDs } - func observedPolicyInvalidationCount() -> Int { policyInvalidationCount } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohActiveBindingConnectionQuotaTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohActiveBindingConnectionQuotaTests.swift deleted file mode 100644 index 03673653..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohActiveBindingConnectionQuotaTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohActiveBindingConnectionQuotaTests { - private let bindingID = "123e4567-e89b-42d3-a456-426614174001" - - @Test - func permitsReconnectOverlapThenRejectsAThirdSession() { - let quota = CmxIrohActiveBindingConnectionQuota() - - #expect(quota.allowsAdmission( - for: bindingID, - activeBindingIDs: [] - )) - #expect(quota.allowsAdmission( - for: bindingID, - activeBindingIDs: [bindingID] - )) - #expect(!quota.allowsAdmission( - for: bindingID, - activeBindingIDs: [bindingID, bindingID] - )) - } - - @Test - func sessionsFromOtherBindingsDoNotConsumeTheQuota() { - let quota = CmxIrohActiveBindingConnectionQuota() - let otherBindingID = "123e4567-e89b-42d3-a456-426614174099" - - #expect(quota.allowsAdmission( - for: bindingID, - activeBindingIDs: [otherBindingID, otherBindingID, bindingID] - )) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAdmissionAckCodecTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAdmissionAckCodecTests.swift deleted file mode 100644 index 3c4fdc56..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAdmissionAckCodecTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohAdmissionAckCodecTests { - @Test(arguments: [ - CmxIrohAdmissionFrame.acceptedPendingNatTraversal, - CmxIrohAdmissionFrame.acceptedRelayOnly, - CmxIrohAdmissionFrame.denied(code: 1), - CmxIrohAdmissionFrame.clientReady, - CmxIrohAdmissionFrame.serverReady, - ]) - func barrierFrameRoundTripsInEightBytes(_ frame: CmxIrohAdmissionFrame) throws { - let codec = CmxIrohAdmissionAckCodec() - let encoded = codec.encodeFrame(frame) - - #expect(encoded.count == CmxIrohAdmissionAckCodec.frameByteCount) - #expect(try codec.decodeFramePrefix(encoded + Data([0xff])) == frame) - } - - @Test(arguments: [ - CmxIrohAdmissionDecision.accepted, - CmxIrohAdmissionDecision.denied(code: 1), - CmxIrohAdmissionDecision.denied(code: .max), - ]) - func decisionRoundTripsInEightBytes(_ decision: CmxIrohAdmissionDecision) throws { - let codec = CmxIrohAdmissionAckCodec() - let encoded = codec.encode(decision) - - #expect(encoded.count == CmxIrohAdmissionAckCodec.frameByteCount) - #expect(try codec.decodePrefix(encoded + Data([0xff])) == decision) - } - - @Test - func malformedDecisionFailsClosed() throws { - let codec = CmxIrohAdmissionAckCodec() - #expect(throws: CmxIrohAdmissionAckCodecError.incompleteFrame) { - try codec.decodePrefix(Data(repeating: 0, count: 7)) - } - - var invalidMagic = codec.encode(.accepted) - invalidMagic[0] = 0 - #expect(throws: CmxIrohAdmissionAckCodecError.invalidMagic) { - try codec.decodePrefix(invalidMagic) - } - - var invalidVersion = codec.encode(.accepted) - invalidVersion[4] = 2 - #expect(throws: CmxIrohAdmissionAckCodecError.unsupportedVersion(2)) { - try codec.decodePrefix(invalidVersion) - } - - var invalidStatus = codec.encode(.accepted) - invalidStatus[5] = 5 - #expect(throws: CmxIrohAdmissionAckCodecError.invalidStatus(5)) { - try codec.decodePrefix(invalidStatus) - } - - var invalidAcceptedCode = codec.encode(.accepted) - invalidAcceptedCode[7] = 1 - #expect(throws: CmxIrohAdmissionAckCodecError.invalidAcceptedCode(1)) { - try codec.decodePrefix(invalidAcceptedCode) - } - - var invalidReadyCode = codec.encodeFrame(.clientReady) - invalidReadyCode[7] = 1 - #expect( - throws: CmxIrohAdmissionAckCodecError.invalidReadyCode(status: 2, code: 1) - ) { - try codec.decodeFramePrefix(invalidReadyCode) - } - - #expect( - throws: CmxIrohAdmissionAckCodecError.invalidDecisionFrame(.serverReady) - ) { - try codec.decodePrefix(codec.encodeFrame(.serverReady)) - } - - var invalidRelayOnlyCode = codec.encodeFrame(.acceptedRelayOnly) - invalidRelayOnlyCode[7] = 1 - #expect(throws: CmxIrohAdmissionAckCodecError.invalidAcceptedCode(1)) { - try codec.decodeFramePrefix(invalidRelayOnlyCode) - } - } - - @Test - func relayOnlyAcceptanceIsAnAcceptedDecision() throws { - let codec = CmxIrohAdmissionAckCodec() - #expect( - try codec.decodePrefix(codec.encodeFrame(.acceptedRelayOnly)) == .accepted - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAdmittedConnectionSupervisorTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAdmittedConnectionSupervisorTests.swift deleted file mode 100644 index d49f8663..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAdmittedConnectionSupervisorTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohAdmittedConnectionSupervisorTests { - @Test(arguments: ["control", "lanes", "together", "caller"]) - func firstExitClosesTheConnectionAndStopsLanesExactlyOnce( - trigger: String - ) async { - let control = AsyncStream<Void>.makeStream() - let lanes = AsyncStream<Void>.makeStream() - let started = AsyncStream<Void>.makeStream() - var startedIterator = started.stream.makeAsyncIterator() - let cleanupRecorder = TestIrohEventRecorder() - let childExitRecorder = TestIrohEventRecorder() - let supervisor = CmxIrohAdmittedConnectionSupervisor( - runControl: { - started.continuation.yield() - for await _ in control.stream {} - await childExitRecorder.record("control") - }, - runApplicationLanes: { - started.continuation.yield() - for await _ in lanes.stream {} - await childExitRecorder.record("lanes") - }, - closeConnection: { - await cleanupRecorder.record("connection.close") - }, - stopApplicationLanes: { - await cleanupRecorder.record("lanes.stop") - } - ) - let runTask = Task { - await supervisor.run() - } - defer { - runTask.cancel() - control.continuation.finish() - lanes.continuation.finish() - started.continuation.finish() - } - - #expect(await startedIterator.next() != nil) - #expect(await startedIterator.next() != nil) - switch trigger { - case "control": - control.continuation.finish() - case "lanes": - lanes.continuation.finish() - case "together": - control.continuation.finish() - lanes.continuation.finish() - default: - runTask.cancel() - } - await runTask.value - - // One actor instance owns one admitted connection lifetime. A repeated - // call cannot launch or clean up the same connection again. - await supervisor.run() - - #expect( - await cleanupRecorder.observedEvents() - == ["connection.close", "lanes.stop"] - ) - #expect( - Set(await childExitRecorder.observedEvents()) - == Set(["control", "lanes"]) - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAppInstanceRepositoryTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAppInstanceRepositoryTests.swift deleted file mode 100644 index b203a0d8..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohAppInstanceRepositoryTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohAppInstanceRepositoryTests { - @Test - func identifierIsStableOnlyWithinTheSameAccountAndTag() async throws { - let store = AppInstanceMemoryStore() - let values = UUIDSequence([ - UUID(uuidString: "123e4567-e89b-42d3-a456-426614174001")!, - UUID(uuidString: "123e4567-e89b-42d3-a456-426614174002")!, - UUID(uuidString: "123e4567-e89b-42d3-a456-426614174003")!, - ]) - let repository = CmxIrohAppInstanceRepository( - store: store, - makeUUID: { values.next() } - ) - - let first = try await repository.appInstanceID(accountID: "account-a", tag: "default") - let repeated = try await repository.appInstanceID(accountID: "account-a", tag: "default") - let switchedAccount = try await repository.appInstanceID( - accountID: "account-b", - tag: "default" - ) - let switchedTag = try await repository.appInstanceID(accountID: "account-b", tag: "dev") - - #expect(first == repeated) - #expect(first != switchedAccount) - #expect(switchedAccount != switchedTag) - #expect(first == first.lowercased()) - } - - @Test - func deactivationNeverReusesThePriorBindingIdentity() async throws { - let store = AppInstanceMemoryStore() - let values = UUIDSequence([ - UUID(uuidString: "123e4567-e89b-42d3-a456-426614174011")!, - UUID(uuidString: "123e4567-e89b-42d3-a456-426614174012")!, - ]) - let repository = CmxIrohAppInstanceRepository( - store: store, - makeUUID: { values.next() } - ) - let first = try await repository.appInstanceID(accountID: "account", tag: "default") - - await repository.deactivate() - let second = try await repository.appInstanceID(accountID: "account", tag: "default") - - #expect(first != second) - } -} - -private final class AppInstanceMemoryStore: CmxIrohInstallStateStoring, @unchecked Sendable { - private let lock = NSLock() - private var values: [String: String] = [:] - - func string(forKey key: String) -> String? { - lock.withLock { values[key] } - } - - func set(_ value: String?, forKey key: String) { - lock.withLock { values[key] = value } - } -} - -private final class UUIDSequence: @unchecked Sendable { - private let lock = NSLock() - private var values: [UUID] - - init(_ values: [UUID]) { self.values = values } - - func next() -> UUID { - lock.withLock { values.removeFirst() } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerClientTestSupport.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerClientTestSupport.swift deleted file mode 100644 index d6506c07..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerClientTestSupport.swift +++ /dev/null @@ -1,111 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -final class BrokerRedirectURLProtocol: URLProtocol, @unchecked Sendable { - private static let lock = NSLock() - nonisolated(unsafe) private static var destination: URL? - nonisolated(unsafe) private static var captured: [URLRequest] = [] - - static func reset(destination: URL) { - lock.lock() - self.destination = destination - captured.removeAll() - lock.unlock() - } - - static func capturedDestinationRequests() -> [URLRequest] { - lock.lock() - defer { lock.unlock() } - return captured - } - - override class func canInit(with request: URLRequest) -> Bool { - ["cmux.example", "attacker.example"].contains(request.url?.host) - } - - override class func canonicalRequest(for request: URLRequest) -> URLRequest { - request - } - - override func startLoading() { - guard let url = request.url else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - if url.path == "/capture" { - Self.lock.lock() - Self.captured.append(request) - Self.lock.unlock() - let response = HTTPURLResponse( - url: url, - statusCode: 200, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data("{}".utf8)) - client?.urlProtocolDidFinishLoading(self) - return - } - - Self.lock.lock() - let destination = Self.destination - Self.lock.unlock() - guard let destination else { - client?.urlProtocol(self, didFailWithError: URLError(.badURL)) - return - } - var redirected = request - redirected.url = destination - let response = HTTPURLResponse( - url: url, - statusCode: 302, - httpVersion: nil, - headerFields: ["Location": destination.absoluteString] - )! - client?.urlProtocol(self, wasRedirectedTo: redirected, redirectResponse: response) - } - - override func stopLoading() {} -} - -actor RecordingBrokerTransport: CmxIrohHTTPTransport { - struct Response: Sendable { - let status: Int - let body: Data - let headers: [String: String] - - static func json( - status: Int, - body: String, - headers: [String: String] = [:] - ) -> Self { - Self(status: status, body: Data(body.utf8), headers: headers) - } - } - - private var pending: [Response] - private var captured: [URLRequest] = [] - private let failure: URLError.Code? - - init(responses: [Response], failure: URLError.Code? = nil) { - pending = responses - self.failure = failure - } - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - captured.append(request) - if let failure { throw URLError(failure) } - let response = pending.removeFirst() - let http = HTTPURLResponse( - url: request.url!, - statusCode: response.status, - httpVersion: nil, - headerFields: ["Content-Type": "application/json"] - .merging(response.headers) { _, new in new } - )! - return (response.body, http) - } - - func requests() -> [URLRequest] { captured } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift deleted file mode 100644 index 79bbe5ad..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohBrokerCredentialRepositoryTests.swift +++ /dev/null @@ -1,484 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh broker credential repository") -struct CmxIrohBrokerCredentialRepositoryTests { - private let now = Date(timeIntervalSince1970: 1_800_000_000) - private let relayFleet = [ - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ] - - @Test("credential descriptions redact opaque tokens") - func credentialDescriptionsRedactTokens() { - let credential = relayResponse().credentials[0] - - #expect(!String(describing: credential).contains(credential.token)) - #expect(!String(reflecting: credential).contains(credential.token)) - #expect(String(describing: credential).contains("<redacted>")) - } - - @Test("binding metadata and relay credentials survive repository recreation") - func roundTripsDurableState() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let binding = try metadata() - let response = relayResponse() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - let recreated = makeRepository(defaults: defaults, secureStore: secureStore) - #expect( - try await recreated.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) == binding - ) - #expect( - try await recreated.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == response - ) - #expect( - await secureStore.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - #expect( - !defaults.dictionaryRepresentation().values.contains(where: { value in - response.credentials.contains { credential in - String(describing: value).contains(credential.token) - } - }) - ) - } - - @Test("distinct per-relay credentials survive device-only persistence") - func roundTripsDistinctPerRelayCredentials() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let binding = try metadata() - let response = CmxIrohRelayTokenResponse(credentials: [ - CmxIrohManagedRelayCredential( - relayURL: relayFleet[0], - token: "abc234", - expiresAt: iso8601(now.addingTimeInterval(2 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(60 * 60)) - ), - CmxIrohManagedRelayCredential( - relayURL: relayFleet[1], - token: "def567", - expiresAt: iso8601(now.addingTimeInterval(3 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(90 * 60)) - ), - ]) - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == response - ) - let stored = try #require(await secureStore.onlyStoredData()) - let object = try #require( - JSONSerialization.jsonObject(with: stored) as? [String: Any] - ) - #expect(object["version"] as? Int == 2) - #expect(object["token"] == nil) - #expect(object["response"] != nil) - } - - @Test("version-one homogeneous credentials migrate without a new network mint") - func loadsVersionOneCredentialRecord() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let binding = try metadata() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let legacyResponse = relayResponse() - - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - legacyResponse, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - let account = try #require(await secureStore.lastDeletedOrWrittenAccount()) - let bindingObject = try JSONSerialization.jsonObject( - with: JSONEncoder().encode(binding) - ) - let legacyRecord: [String: Any] = [ - "version": 1, - "binding": bindingObject, - "token": "abc234", - "expiresAt": iso8601(now.addingTimeInterval(2 * 60 * 60)), - "refreshAfter": iso8601(now.addingTimeInterval(60 * 60)), - "relayFleet": relayFleet, - ] - await secureStore.seed( - try JSONSerialization.data(withJSONObject: legacyRecord), - account: account - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == legacyResponse - ) - } - - @Test("a different account or app instance cannot resurrect prior state") - func scopeRotationDeletesPriorState() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let original = try metadata() - - try await repository.saveBinding(original, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadBinding( - accountID: "account-b", - appInstanceID: original.appInstanceID - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - - let replacementAppInstanceID = "123e4567-e89b-42d3-a456-426614174099" - #expect( - try await repository.loadBinding( - accountID: "account-b", - appInstanceID: replacementAppInstanceID - ) == nil - ) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: original.appInstanceID - ) == nil - ) - #expect(await secureStore.deleteAllCount() == 4) - } - - @Test("replacing the exact broker binding invalidates its relay capability") - func bindingRotationDeletesRelayCredential() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let original = try metadata() - let rotated = try metadata( - bindingID: "123e4567-e89b-42d3-a456-426614174020", - endpointByte: "cd", - generation: 2 - ) - - try await repository.saveBinding(original, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) - try await repository.saveBinding(rotated, accountID: "account-a") - - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: original.appInstanceID - ) == rotated - ) - #expect(await secureStore.recordCount() == 0) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: original, - expectedRelayFleet: Set(relayFleet), - now: now - ) == nil - ) - } - - @Test("saving an incomplete relay fleet fails without persisting the token") - func saveRejectsFleetMismatch() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - - await #expect( - throws: CmxIrohBrokerCredentialRepositoryError.relayFleetMismatch - ) { - try await repository.saveRelayCredential( - relayResponse(relayFleet: [relayFleet[0]]), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - } - #expect(await secureStore.recordCount() == 0) - } - - @Test("loading with a changed managed fleet deletes the stale capability") - func loadRejectsFleetMismatch() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set([relayFleet[0]]), - now: now - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("expired or refresh-stale relay capabilities are deleted") - func loadRejectsStaleCredential() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - let response = relayResponse() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(60 * 60) - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - - try await repository.saveRelayCredential( - response, - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now.addingTimeInterval(2 * 60 * 60) - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("corrupt secure records fail closed and are removed") - func corruptCredentialIsDeleted() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - let account = try #require(await secureStore.lastDeletedOrWrittenAccount()) - await secureStore.seed(Data("not-json".utf8), account: account) - - #expect( - try await repository.loadRelayCredential( - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) == nil - ) - #expect(await secureStore.recordCount() == 0) - } - - @Test("persisted binding metadata is revalidated during decoding") - func corruptBindingMetadataIsRejected() throws { - let binding = try metadata() - let encoded = try JSONEncoder().encode(binding) - var object = try #require( - JSONSerialization.jsonObject(with: encoded) as? [String: Any] - ) - object["bindingID"] = "not-a-uuid" - let corrupted = try JSONSerialization.data(withJSONObject: object) - - #expect(throws: CmxIrohBrokerCredentialRepositoryError.invalidBinding) { - try JSONDecoder().decode( - CmxIrohBrokerBindingMetadata.self, - from: corrupted - ) - } - } - - @Test("explicit deletion preserves or clears binding metadata as requested") - func explicitDeletion() async throws { - let (defaults, suiteName) = try isolatedDefaults() - defer { defaults.removePersistentDomain(forName: suiteName) } - let secureStore = TestSecureCredentialStore() - let repository = makeRepository(defaults: defaults, secureStore: secureStore) - let binding = try metadata() - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.saveRelayCredential( - relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: Set(relayFleet), - now: now - ) - - try await repository.deleteRelayCredential( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) - #expect(await secureStore.recordCount() == 0) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) == binding - ) - - try await repository.deleteBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) == nil - ) - - try await repository.saveBinding(binding, accountID: "account-a") - try await repository.deactivate() - #expect( - try await repository.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) == nil - ) - } - - private func makeRepository( - defaults: UserDefaults, - secureStore: TestSecureCredentialStore - ) -> CmxIrohBrokerCredentialRepository { - CmxIrohBrokerCredentialRepository( - secureStore: secureStore, - installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults) - ) - } - - private func isolatedDefaults() throws -> (UserDefaults, String) { - let suiteName = "CmxIrohBrokerCredentialRepositoryTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defaults.removePersistentDomain(forName: suiteName) - return (defaults, suiteName) - } - - private func metadata( - bindingID: String = "123e4567-e89b-42d3-a456-426614174010", - endpointByte: String = "ab", - generation: Int = 1 - ) throws -> CmxIrohBrokerBindingMetadata { - try CmxIrohBrokerBindingMetadata( - bindingID: bindingID, - deviceID: "123e4567-e89b-42d3-a456-426614174011", - appInstanceID: "123e4567-e89b-42d3-a456-426614174012", - tag: "cmux-ios-v0", - platform: .mac, - endpointID: CmxIrohPeerIdentity( - endpointID: String(repeating: endpointByte, count: 32) - ), - identityGeneration: generation - ) - } - - private func relayResponse( - relayFleet: [String]? = nil - ) -> CmxIrohRelayTokenResponse { - CmxIrohRelayTokenResponse( - token: "abc234", - expiresAt: iso8601(now.addingTimeInterval(2 * 60 * 60)), - refreshAfter: iso8601(now.addingTimeInterval(60 * 60)), - relayFleet: relayFleet ?? self.relayFleet - ) - } - - private func iso8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: date) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohByteTransportTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohByteTransportTests.swift deleted file mode 100644 index 562aedd8..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohByteTransportTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohByteTransportTests { - @Test - func factoryConnectsIrohRouteThroughInjectedSupervisorAndContext() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ab", count: 32) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "cd", count: 32) - ) - let admissionCodec = CmxIrohAdmissionAckCodec() - let controlReceive = TestIrohReceiveStream( - buffer: admissionCodec.encode(.accepted) - + admissionCodec.encodeFrame(.serverReady) - + Data("response".utf8) - ) - let controlSend = TestIrohSendStream() - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [ - CmxIrohBidirectionalStream( - receiveStream: controlReceive, - sendStream: controlSend - ), - ] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let endpointFactory = TestIrohEndpointFactory(endpoints: [endpoint]) - let supervisor = CmxIrohEndpointSupervisor( - factory: endpointFactory, - configuration: try endpointConfiguration() - ) - _ = try await supervisor.activate() - let credential = try CmxIrohAdmissionCredential.pairGrant("e30.e30.AA") - let contextProvider = TestIrohClientContextProvider( - context: CmxIrohClientContext( - dialPlan: try testIrohDialPlan(), - credential: credential - ) - ) - let route = try CmxAttachRoute( - id: "iroh", - kind: .iroh, - endpoint: .peer(identity: remoteIdentity, pathHints: []), - priority: 0 - ) - let factory = CmxIrohByteTransportFactory( - supervisor: supervisor, - contextProvider: contextProvider - ) - let request = CmxByteTransportRequest( - route: route, - expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174004", - authorizationMode: .transportAdmission - ) - let transport = try factory.makeTransport(for: request) - - try await transport.connect() - try await transport.send(Data("request".utf8)) - - #expect(try await transport.receive() == Data("response".utf8)) - #expect(await contextProvider.requests() == [request]) - let sent = await controlSend.observedSentBuffers() - #expect(sent.count == 3) - #expect(sent[1] == admissionCodec.encodeFrame(.clientReady)) - #expect(sent[2] == Data("request".utf8)) - } - - @Test - func factoryRejectsLegacyHostPortRoutes() throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ab", count: 32) - ) - let endpoint = TestIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try endpointConfiguration() - ) - let provider = TestIrohClientContextProvider( - context: CmxIrohClientContext( - dialPlan: try testIrohDialPlan(), - credential: try .pairGrant("e30.e30.AA") - ) - ) - let factory = CmxIrohByteTransportFactory( - supervisor: supervisor, - contextProvider: provider - ) - let tailscale = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "100.64.0.1", port: 42), - priority: 0 - ) - - #expect(throws: CmxIrohByteTransportError.unsupportedRouteKind(.tailscale)) { - try factory.makeTransport(for: tailscale) - } - } - - private func endpointConfiguration() throws -> CmxIrohEndpointConfiguration { - try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 1, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientOfflinePolicyCacheCompatibilityTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientOfflinePolicyCacheCompatibilityTests.swift deleted file mode 100644 index e76caf47..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientOfflinePolicyCacheCompatibilityTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohClientOfflinePolicyCacheTests { - @Test("legacy uppercase UUID loads canonical cached target") - func uppercaseRequestDeviceIDLoadsCanonicalTarget() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: expectation, - now: fixture.now - ) - - let loaded = try await cache.load( - for: fixture.request( - hints: [], - expectedPeerDeviceID: fixture.acceptor.deviceID.uppercased() - ), - localBinding: discovery.bindings[0], - expectation: expectation, - confirmedDiscovery: nil, - now: fixture.now - ) - - #expect(loaded?.targetBinding == discovery.bindings[1]) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientOfflinePolicyCacheTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientOfflinePolicyCacheTests.swift deleted file mode 100644 index 8db63037..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientOfflinePolicyCacheTests.swift +++ /dev/null @@ -1,488 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh client offline policy cache") -struct CmxIrohClientOfflinePolicyCacheTests { - @Test("verified target policy round-trips with device-only protection") - func roundTripsVerifiedPolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let grant = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: grant, - for: expectation, - now: fixture.now - ) - - let recreated = CmxIrohClientOfflinePolicyCache(secureStore: store) - let loaded = try await recreated.load( - for: fixture.request(hints: []), - localBinding: discovery.bindings[0], - expectation: expectation, - confirmedDiscovery: nil, - now: fixture.now - ) - #expect(loaded?.localBinding == discovery.bindings[0]) - #expect(loaded?.targetBinding == discovery.bindings[1]) - #expect(loaded?.pairGrant == grant) - #expect(loaded?.lanRendezvous == discovery.lanRendezvous) - #expect(await store.observedAccessibilities() == [.afterFirstUnlockThisDeviceOnly]) - } - - @Test("save rejects grants that do not bind the exact target") - func saveRejectsSubstitutedTarget() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let substituted = try fixture.discovery( - targetHints: [], - targetDeviceID: "123e4567-e89b-42d3-a456-426614174099" - ) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - - await #expect(throws: CmxIrohGrantVerifierError.identityMismatch) { - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: substituted.bindings[1], - discovery: substituted, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ), - for: fixture.offlineExpectation(), - now: fixture.now - ) - } - #expect(await store.recordCount() == 0) - } - - @Test("load re-verifies expiry and deletes stale authority") - func loadDeletesExpiredGrant() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let cacheStore = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: cacheStore) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 60 - ), - for: expectation, - now: fixture.now - ) - - let loaded = try await cache.load( - for: fixture.request(hints: []), - localBinding: discovery.bindings[0], - expectation: expectation, - confirmedDiscovery: nil, - now: fixture.now.addingTimeInterval(61) - ) - - #expect(loaded == nil) - #expect(await cacheStore.recordCount() == 0) - } - - @Test("account and local identity changes wipe the active cache") - func changedScopeDeletesPolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: fixture.offlineExpectation(), - now: fixture.now - ) - - #expect(try await cache.loadBootstrap( - for: fixture.offlineExpectation(accountID: "account-b"), - confirmedLocalBinding: nil, - now: fixture.now - ) == nil) - #expect(await store.recordCount() == 0) - } - - @Test("unknown and substituted Mac tuples never receive cached authority") - func requestMustMatchKnownTargetTuple() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: expectation, - now: fixture.now - ) - - let unknown = try fixture.request( - hints: [], - expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174099" - ) - #expect(try await cache.load( - for: unknown, - localBinding: discovery.bindings[0], - expectation: expectation, - confirmedDiscovery: nil, - now: fixture.now - ) == nil) - #expect(await store.recordCount() == 1) - } - - @Test("corrupt records and changed relay fleets are deleted") - func corruptAndWrongFleetDeletePolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: fixture.offlineExpectation(), - now: fixture.now - ) - let account = try #require(await store.lastDeletedOrWrittenAccount()) - await store.seed(Data("not-json".utf8), account: account) - #expect(try await cache.loadBootstrap( - for: fixture.offlineExpectation(), - confirmedLocalBinding: nil, - now: fixture.now - ) == nil) - #expect(await store.recordCount() == 0) - - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: fixture.offlineExpectation(), - now: fixture.now - ) - #expect(try await cache.loadBootstrap( - for: fixture.offlineExpectation( - managedRelayURLs: ["https://other.example.com/"] - ), - confirmedLocalBinding: nil, - now: fixture.now - ) == nil) - #expect(await store.recordCount() == 0) - } - - @Test("changed local identity and confirmed target revocation delete authority") - func localIdentityAndConfirmedRevocationDeletePolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let grant = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ) - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: grant, - for: fixture.offlineExpectation(), - now: fixture.now - ) - let changedLocal = try CmxIrohLocalBindingExpectation( - deviceID: fixture.initiator.deviceID, - appInstanceID: discovery.bindings[0].appInstanceID, - tag: fixture.initiator.tag, - platform: .ios, - endpointID: fixture.initiator.endpointID, - identityGeneration: fixture.initiator.identityGeneration + 1, - pairingEnabled: false, - capabilities: discovery.bindings[0].capabilities - ) - #expect(try await cache.loadBootstrap( - for: fixture.offlineExpectation(localExpectation: changedLocal), - confirmedLocalBinding: nil, - now: fixture.now - ) == nil) - #expect(await store.recordCount() == 0) - - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: grant, - for: fixture.offlineExpectation(), - now: fixture.now - ) - let revoked = try fixture.discovery(targetHints: [], includeTarget: false) - #expect(try await cache.load( - for: fixture.request(hints: []), - localBinding: discovery.bindings[0], - expectation: fixture.offlineExpectation(), - confirmedDiscovery: revoked, - now: fixture.now - ) == nil) - #expect(await store.recordCount() == 0) - } - - @Test("deactivate invalidates a suspended save before it can repopulate policy") - func deactivateInvalidatesSuspendedSave() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = SuspendingSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - await store.suspendNextRead() - let saveTask = Task { - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: fixture.offlineExpectation(), - now: fixture.now - ) - } - await store.waitUntilReadIsSuspended() - - try await cache.deactivate() - #expect(await store.recordCount() == 0) - await store.resumeSuspendedRead() - - await #expect(throws: CancellationError.self) { - try await saveTask.value - } - #expect(await store.recordCount() == 0) - } - - @Test("deactivate invalidates a suspended bootstrap load after deleting policy") - func deactivateInvalidatesSuspendedBootstrapLoad() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = SuspendingSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: expectation, - now: fixture.now - ) - await store.suspendNextRead() - let loadTask = Task { - try await cache.loadBootstrap( - for: expectation, - confirmedLocalBinding: discovery.bindings[0], - now: fixture.now - ) - } - await store.waitUntilReadIsSuspended() - - try await cache.deactivate() - #expect(await store.recordCount() == 0) - await store.resumeSuspendedRead() - - await #expect(throws: CancellationError.self) { - try await loadTask.value - } - #expect(await store.recordCount() == 0) - } - - @Test("deactivate drains a suspended write before its final delete") - func deactivateDrainsSuspendedWriteBeforeFinalDelete() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = SuspendingSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - await store.suspendNextWrite() - let saveTask = Task { - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: expectation, - now: fixture.now - ) - } - await store.waitUntilWriteIsSuspended() - #expect(await store.recordCount() == 0) - - let deactivateTask = Task { - try await cache.deactivate() - } - let probeRequest = CmxByteTransportRequest( - route: try fixture.route(hints: []), - expectedPeerDeviceID: fixture.acceptor.deviceID, - authorizationMode: .stackBearer - ) - var observedDeactivation = false - for _ in 0 ..< 1_024 where !observedDeactivation { - do { - let result = try await cache.load( - for: probeRequest, - localBinding: discovery.bindings[0], - expectation: expectation, - confirmedDiscovery: nil, - now: fixture.now - ) - #expect(result == nil) - await Task.yield() - } catch is CancellationError { - observedDeactivation = true - } - } - #expect(observedDeactivation) - #expect(await store.deleteAllCallCount() == 0) - - await store.resumeSuspendedWrite() - await #expect(throws: CancellationError.self) { - try await saveTask.value - } - try await deactivateTask.value - - #expect(await store.deleteAllCallCount() == 1) - #expect(await store.recordCount() == 0) - } -} - -private actor SuspendingSecureCredentialStore: CmxIrohSecureCredentialStoring { - private var records: [String: Data] = [:] - private var shouldSuspendNextRead = false - private var suspendedRead: CheckedContinuation<Void, Never>? - private var readSuspensionWaiters: [CheckedContinuation<Void, Never>] = [] - private var shouldSuspendNextWrite = false - private var suspendedWrite: CheckedContinuation<Void, Never>? - private var writeSuspensionWaiters: [CheckedContinuation<Void, Never>] = [] - private var deleteAllCalls = 0 - - func read(account: String) async -> Data? { - let captured = records[account] - guard shouldSuspendNextRead else { return captured } - shouldSuspendNextRead = false - await withCheckedContinuation { continuation in - suspendedRead = continuation - let waiters = readSuspensionWaiters - readSuspensionWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume() - } - } - return captured - } - - func write( - _ data: Data, - account: String, - accessibility _: CmxIrohSecureCredentialAccessibility - ) async { - if shouldSuspendNextWrite { - shouldSuspendNextWrite = false - await withCheckedContinuation { continuation in - suspendedWrite = continuation - let waiters = writeSuspensionWaiters - writeSuspensionWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { - waiter.resume() - } - } - } - records[account] = data - } - - func delete(account: String) { - records.removeValue(forKey: account) - } - - func deleteAll() { - records.removeAll(keepingCapacity: false) - deleteAllCalls += 1 - } - - func suspendNextRead() { - shouldSuspendNextRead = true - } - - func waitUntilReadIsSuspended() async { - guard suspendedRead == nil else { return } - await withCheckedContinuation { continuation in - readSuspensionWaiters.append(continuation) - } - } - - func resumeSuspendedRead() { - let continuation = suspendedRead - suspendedRead = nil - continuation?.resume() - } - - func suspendNextWrite() { - shouldSuspendNextWrite = true - } - - func waitUntilWriteIsSuspended() async { - guard suspendedWrite == nil else { return } - await withCheckedContinuation { continuation in - writeSuspensionWaiters.append(continuation) - } - } - - func resumeSuspendedWrite() { - let continuation = suspendedWrite - suspendedWrite = nil - continuation?.resume() - } - - func deleteAllCallCount() -> Int { - deleteAllCalls - } - - func recordCount() -> Int { - records.count - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift deleted file mode 100644 index 1896105f..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeAuthorizationTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohClientRuntimeTests { - @Test - func connectivityOnlyStartupRestoresVerifiedKnownMacRoutes() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 3_600 - ), - for: expectation, - now: fixture.now - ) - let identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), - generation: fixture.initiator.identityGeneration - ) - let configuration = CmxIrohClientRuntimeConfiguration( - accountID: "account-a", - deviceID: fixture.initiator.deviceID, - appInstanceID: discovery.bindings[0].appInstanceID, - tag: fixture.initiator.tag, - displayName: nil, - identity: identity, - capabilities: discovery.bindings[0].capabilities, - managedRelayURLs: [fixture.relayURL] - ) - let relay = CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-01-15T10:00:00Z", - refreshAfter: "2027-01-15T09:00:00Z", - relayFleet: [fixture.relayURL] - ) - let broker = TestIrohClientBroker( - binding: discovery.bindings[0], - discovery: discovery, - relay: relay, - registrationError: CmxIrohTrustBrokerClientError.connectivity - ) - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.initiator.endpointID)] - ), - broker: broker, - configuration: configuration, - pendingRevocations: CmxIrohPendingRevocationOutbox( - secureStore: TestSecureCredentialStore() - ), - offlinePolicyCache: cache, - now: { fixture.now }, - handleCachedBindings: { bindings, _ in - await recorder.recordCachedBindings(bindings) - } - ) - - try await runtime.start() - - #expect(await runtime.snapshot().state == .active) - #expect(await runtime.snapshot().bindingID == discovery.bindings[0].bindingID) - #expect(await recorder.observedCachedBindingDeviceIDs() == [[fixture.acceptor.deviceID]]) - await runtime.stop() - #expect(await store.recordCount() == 1) - } - - @Test - func authenticatedStartupFailureNeverConsultsOfflinePolicy() async throws { - let fixture = try ClientRuntimeTestFixture() - let store = TestSecureCredentialStore() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - registrationError: CmxIrohTrustBrokerClientError.rejected( - statusCode: 401, - code: "unauthorized" - ) - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - offlinePolicyCache: CmxIrohClientOfflinePolicyCache(secureStore: store), - now: { fixture.now } - ) - - await #expect(throws: CmxIrohTrustBrokerClientError.rejected( - statusCode: 401, - code: "unauthorized" - )) { - try await runtime.start() - } - #expect(await store.readCount() == 0) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift deleted file mode 100644 index 08afb90b..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeLifecycleRaceTests.swift +++ /dev/null @@ -1,405 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohClientRuntimeTests { - @Test("foreground recovery owns the registration lane while the endpoint is unbound") - func foregroundRecoverySerializesRegistrationAcrossEndpointReplacement() async throws { - let fixture = try ClientRuntimeTestFixture() - let staleEndpoint = ClientRuntimeBlockingCloseEndpoint( - identity: fixture.endpointID - ) - let replacementEndpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory( - endpoints: [staleEndpoint, replacementEndpoint] - ) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let configuration = CmxIrohClientRuntimeConfiguration( - accountID: fixture.configuration.accountID, - deviceID: fixture.configuration.deviceID, - appInstanceID: fixture.configuration.appInstanceID, - tag: fixture.configuration.tag, - displayName: fixture.configuration.displayName, - identity: fixture.configuration.identity, - capabilities: fixture.configuration.capabilities, - managedRelayURLs: fixture.configuration.managedRelayURLs, - endpointRelayProfile: .unavailableCustomOverride - ) - let runtime = try CmxIrohClientRuntime( - factory: factory, - broker: broker, - configuration: configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - await staleEndpoint.setHealthy(false) - - let foreground = Task { try await runtime.didBecomeActive() } - await staleEndpoint.waitForCloseStart() - await runtime.handleSupervisorNetworkChange( - revision: await runtime.lifecycleRevision - ) - if let concurrentRefresh = await runtime.registrationRefreshTask { - _ = try? await concurrentRefresh.value - } - await staleEndpoint.releaseClose() - - switch await foreground.result { - case .success: - break - case .failure(let error): - Issue.record("foreground recovery was superseded by its own refresh: \(error)") - } - #expect(await runtime.snapshot().state == .active) - #expect(await factory.observedConfigurations().count == 2) - #expect(await broker.observedRegistrations().count == 2) - #expect(await staleEndpoint.observedCloseCallCount() == 1) - await runtime.stop() - await runtime.supervisor.deactivate() - } - - @Test - func stoppedStartupCannotPublishDiscoveryGenerationAfterBindingHandlerResumes() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = ClientRuntimeBindingHandlerGate(blockedCalls: [1]) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now }, - handleBinding: { _, _ in - await gate.handleBinding() - return true - } - ) - let start = Task { try await runtime.start() } - await gate.waitForCall(1) - - await runtime.stop() - await gate.release(call: 1) - - switch await start.result { - case .success: - Issue.record("superseded startup unexpectedly succeeded") - case .failure(let error): - #expect(error as? CmxIrohClientRuntimeError == .superseded) - } - #expect(await runtime.liveDiscoverySnapshotGeneration() == 0) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func stoppedRefreshCannotPublishDiscoveryGenerationAfterBindingHandlerResumes() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = ClientRuntimeBindingHandlerGate(blockedCalls: [2]) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now }, - handleBinding: { _, _ in - await gate.handleBinding() - return true - } - ) - try await runtime.start() - #expect(await runtime.liveDiscoverySnapshotGeneration() == 1) - let refresh = Task { await runtime.refreshLiveDiscovery() } - await gate.waitForCall(2) - - await runtime.stop() - await gate.release(call: 2) - - #expect(!(await refresh.value)) - #expect(await runtime.liveDiscoverySnapshotGeneration() == 1) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func refreshAwaitsAlreadyScheduledSuccessorWithoutRequestingThirdRefresh() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let secondRegistration = HostRuntimeRegistrationGate() - let thirdRegistration = HostRuntimeRegistrationGate() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - registrationHook: { count in - if count == 2 { await secondRegistration.waitOnce() } - if count == 3 { await thirdRegistration.waitOnce() } - } - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - await broker.setRegistrationError( - CmxIrohTrustBrokerClientError.connectivity, - forRegistrationCount: 2 - ) - let refresh = Task { await runtime.refreshLiveDiscovery() } - await broker.waitForRegistrationCount(2) - await runtime.handleSupervisorNetworkChange( - revision: await runtime.lifecycleRevision - ) - #expect(await runtime.registrationRefreshPending) - - await secondRegistration.open() - await broker.waitForRegistrationCount(3) - #expect(await runtime.registrationRefreshTaskID != nil) - await thirdRegistration.open() - - #expect(await refresh.value) - #expect(await runtime.registrationRefreshTaskID == nil) - #expect(!(await runtime.registrationRefreshPending)) - #expect(await broker.observedRegistrations().count == 3) - await runtime.stop() - } - - @Test - func networkChangeDuringRegistrationRequestsRefreshAfterStartup() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - registrationHook: { count in - if count == 1 { await endpoint.emit(.networkChanged) } - } - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - - try await runtime.start() - - #expect( - await broker.waitForRegistrationCount(2, timeout: .seconds(1)) - ) - await runtime.stop() - } - - @Test - func networkChangeDuringActiveRefreshRequestsAnotherRegistration() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeRegistrationGate() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - registrationHook: { count in - if count == 2 { await gate.waitOnce() } - } - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - await endpoint.emit(.networkChanged) - await gate.open() - - #expect( - await broker.waitForRegistrationCount(3, timeout: .seconds(1)) - ) - await runtime.stop() - } - - @Test - func stoppedRuntimeIgnoresSupersededRefreshFailure() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeRegistrationGate() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - registrationHook: { count in - if count == 2 { await gate.waitOnce() } - } - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - let refresh = await runtime.registrationRefreshTask - - await runtime.stop() - await gate.open() - _ = try? await refresh?.value - - #expect(await runtime.snapshot().state == .inactive) - #expect(await endpoint.observedCloseCallCount() == 1) - } - - @Test - func signedOutRuntimeIgnoresSupersededRefreshFailure() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeRegistrationGate() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - registrationHook: { count in - if count == 2 { await gate.waitOnce() } - } - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - let refresh = await runtime.registrationRefreshTask - - let preparation = await runtime.deactivateForSignOut() - await gate.open() - _ = try? await refresh?.value - - #expect(preparation.wasPersisted) - #expect(await runtime.snapshot().state == .inactive) - #expect(await endpoint.observedCloseCallCount() == 1) - } -} - -private actor ClientRuntimeBlockingCloseEndpoint: CmxIrohEndpoint { - private let peerIdentity: CmxIrohPeerIdentity - private let healthStream: AsyncStream<CmxIrohEndpointHealthEvent> - private let healthContinuation: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - private var healthy = true - private var closeCallCount = 0 - private var closeStarted = false - private var closeStartWaiters: [CheckedContinuation<Void, Never>] = [] - private var closeRelease: CheckedContinuation<Void, Never>? - - init(identity: CmxIrohPeerIdentity) { - peerIdentity = identity - let health = AsyncStream<CmxIrohEndpointHealthEvent>.makeStream() - healthStream = health.stream - healthContinuation = health.continuation - } - - func identity() -> CmxIrohPeerIdentity { peerIdentity } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: peerIdentity, pathHints: []) - } - - func connect( - to _: CmxIrohEndpointAddress, - alpn _: Data - ) async throws -> any CmxIrohConnection { - throw TestIrohTransportError.unsupported - } - - func accept() async throws -> (any CmxIrohConnection)? { nil } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { healthStream } - - func isHealthy() -> Bool { healthy } - - func close() async { - closeCallCount += 1 - closeStarted = true - let waiters = closeStartWaiters - closeStartWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { closeRelease = $0 } - healthContinuation.finish() - } - - func setHealthy(_ value: Bool) { healthy = value } - - func waitForCloseStart() async { - if closeStarted { return } - await withCheckedContinuation { closeStartWaiters.append($0) } - } - - func releaseClose() { - closeRelease?.resume() - closeRelease = nil - } - - func observedCloseCallCount() -> Int { closeCallCount } -} - -private actor ClientRuntimeBindingHandlerGate { - private let blockedCalls: Set<Int> - private var callCount = 0 - private var observedCalls: Set<Int> = [] - private var callWaiters: [Int: [CheckedContinuation<Void, Never>]] = [:] - private var releaseWaiters: [Int: CheckedContinuation<Void, Never>] = [:] - private var releasedCalls: Set<Int> = [] - - init(blockedCalls: Set<Int>) { - self.blockedCalls = blockedCalls - } - - func handleBinding() async { - callCount += 1 - let call = callCount - observedCalls.insert(call) - let waiters = callWaiters.removeValue(forKey: call) ?? [] - for waiter in waiters { waiter.resume() } - guard blockedCalls.contains(call), !releasedCalls.contains(call) else { return } - await withCheckedContinuation { releaseWaiters[call] = $0 } - } - - func waitForCall(_ call: Int) async { - if observedCalls.contains(call) { return } - await withCheckedContinuation { callWaiters[call, default: []].append($0) } - } - - func release(call: Int) { - releasedCalls.insert(call) - releaseWaiters.removeValue(forKey: call)?.resume() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift deleted file mode 100644 index 6ab15017..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +++ /dev/null @@ -1,611 +0,0 @@ -import CMUXMobileCore -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohClientRuntimeTests { - @Test - func startInstallsExactIOSBindingAndManagedRelays() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now }, - handleBinding: { _, _ in - await recorder.recordBinding() - return true - }, - handleRelayCredential: { _, _ in await recorder.recordRelay() } - ) - - try await runtime.start() - - let snapshot = await runtime.snapshot() - #expect(snapshot.state == .active) - #expect(snapshot.endpointID == fixture.endpointID) - #expect(snapshot.bindingID == fixture.binding.bindingID) - let prepared = try #require(await broker.observedRegistrations().first) - #expect(prepared.challengeRequest.deviceId == fixture.binding.deviceID) - #expect(prepared.challengeRequest.appInstanceId == fixture.binding.appInstanceID) - #expect(prepared.challengeRequest.tag == fixture.binding.tag) - #expect(prepared.challengeRequest.endpointId == fixture.endpointID.endpointID) - #expect(prepared.challengeRequest.identityGeneration == fixture.identity.generation) - #expect(await endpoint.observedRelayUpdates().last?.count == 4) - #expect(await recorder.observedBindingCount() == 1) - await recorder.waitForRelayCount(1) - #expect(await recorder.observedRelayCount() == 1) - #expect(runtime.transportFactory.supportedKinds == [.iroh]) - await runtime.stop() - } - - @Test - func liveDiscoveryRefreshReturnsTrueOnlyAfterNewVerifiedSnapshot() async throws { - let fixture = try ClientRuntimeTestFixture() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), - ]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now }, - handleBinding: { _, _ in - await recorder.recordBinding() - return true - } - ) - try await runtime.start() - - let initialProvider = try #require(await runtime.registryContextProvider) - #expect(await runtime.refreshLiveDiscovery()) - let refreshedProvider = try #require(await runtime.registryContextProvider) - #expect(await broker.observedRegistrations().count == 2) - #expect(await recorder.observedBindingCount() == 2) - #expect(initialProvider === refreshedProvider) - await runtime.stop() - } - - @Test - func unavailableBrokerReportsOfflineWithoutReusingStaleDiscovery() async throws { - let fixture = try ClientRuntimeTestFixture() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), - ]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now }, - handleBinding: { _, _ in - await recorder.recordBinding() - return true - } - ) - try await runtime.start() - await broker.setRegistrationError(CmxIrohTrustBrokerClientError.connectivity) - - #expect( - await runtime.refreshLiveDiscoveryOutcome() - == .failed(.offline) - ) - #expect(await runtime.snapshot().state == .active) - #expect(await recorder.observedBindingCount() == 1) - await runtime.stop() - } - - @Test - func rateLimitedBrokerReportsPolicyUnavailableWithoutDroppingRuntime() async throws { - let fixture = try ClientRuntimeTestFixture() - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), - ]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - await broker.setRegistrationError( - CmxIrohTrustBrokerClientError.rateLimited( - code: nil, - retryAfterSeconds: 15 - ) - ) - - #expect( - await runtime.refreshLiveDiscoveryOutcome() - == .failed(.policyUnavailable) - ) - #expect(await runtime.snapshot().state == .active) - await runtime.stop() - } - - @Test - func rejectedCatalogPublicationCannotAdvanceLiveDiscoveryGeneration() async throws { - let fixture = try ClientRuntimeTestFixture() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [ - TestIrohEndpoint(identity: fixture.endpointID), - ]), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now }, - handleBinding: { _, _ in false } - ) - - try await runtime.start() - #expect(await runtime.liveDiscoverySnapshotGeneration() == 0) - #expect( - await runtime.refreshLiveDiscoveryOutcome() - == .failed(.superseded) - ) - #expect(await runtime.liveDiscoverySnapshotGeneration() == 0) - await runtime.stop() - } - - @Test - func inactiveRuntimeReportsEndpointUnavailable() async throws { - let fixture = try ClientRuntimeTestFixture() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: []), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - - #expect( - await runtime.refreshLiveDiscoveryOutcome() - == .failed(.endpointUnavailable) - ) - } - - @Test - func discoverySubstitutionFailsClosedAndClosesEndpoint() async throws { - let fixture = try ClientRuntimeTestFixture() - let substitutedDiscovery = try ClientRuntimeTestFixture.discovery( - binding: fixture.binding, - overrideAppInstanceID: "123e4567-e89b-42d3-a456-426614174099" - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: substitutedDiscovery, - relay: fixture.relayResponse() - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - - await #expect(throws: CmxIrohClientRuntimeError.localBindingMissingFromDiscovery) { - try await runtime.start() - } - - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await runtime.snapshot().state == .failed) - } - - @Test - func backgroundPreservesEndpointAndForegroundReusesHealthyGeneration() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let runtime = try CmxIrohClientRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - - await runtime.didEnterBackground() - try await runtime.didBecomeActive() - - #expect(await endpoint.observedCloseCallCount() == 0) - #expect(await factory.observedConfigurations().count == 1) - #expect(await broker.observedRegistrations().count == 2) - #expect(await runtime.snapshot().state == .active) - await runtime.stop() - } - - @Test - func foregroundRecreatesStaleDriverWithStableIdentity() async throws { - let fixture = try ClientRuntimeTestFixture() - let staleEndpoint = TestIrohEndpoint(identity: fixture.endpointID) - let replacementEndpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory( - endpoints: [staleEndpoint, replacementEndpoint] - ) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let runtime = try CmxIrohClientRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - await runtime.didEnterBackground() - await staleEndpoint.setHealthy(false) - - try await runtime.didBecomeActive() - - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 2) - #expect(configurations[0].secretKey == configurations[1].secretKey) - #expect(await staleEndpoint.observedCloseCallCount() == 1) - #expect(await broker.observedRegistrations().count == 2) - #expect(await runtime.snapshot().endpointID == fixture.endpointID) - await runtime.stop() - } - - @Test - func foregroundTerminalBrokerFailureRevokesLocalPolicy() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let offlineStore = TestSecureCredentialStore() - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - offlinePolicyCache: CmxIrohClientOfflinePolicyCache( - secureStore: offlineStore - ), - now: { fixture.now }, - handlePolicyInvalidation: { - await recorder.recordPolicyInvalidation() - } - ) - try await runtime.start() - let terminal = CmxIrohTrustBrokerClientError.rejected( - statusCode: 401, - code: "unauthorized" - ) - await broker.setRegistrationError(terminal) - - await #expect(throws: terminal) { - try await runtime.didBecomeActive() - } - - #expect(await runtime.snapshot().state == .failed) - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await offlineStore.deleteAllCount() == 1) - #expect(await recorder.observedPolicyInvalidationCount() == 1) - } - - @Test - func foregroundConnectivityFailureKeepsLastVerifiedPolicy() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let offlineStore = TestSecureCredentialStore() - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - offlinePolicyCache: CmxIrohClientOfflinePolicyCache( - secureStore: offlineStore - ), - now: { fixture.now }, - handlePolicyInvalidation: { - await recorder.recordPolicyInvalidation() - } - ) - try await runtime.start() - await broker.setRegistrationError(CmxIrohTrustBrokerClientError.connectivity) - - try await runtime.didBecomeActive() - - #expect(await runtime.snapshot().state == .active) - #expect(await endpoint.observedCloseCallCount() == 0) - #expect(await offlineStore.deleteAllCount() == 0) - #expect(await recorder.observedPolicyInvalidationCount() == 0) - await runtime.stop() - } - - @Test(arguments: [ - CmxIrohTrustBrokerClientError.rejected( - statusCode: 408, - code: "request_timeout" - ), - .rejected(statusCode: 425, code: "too_early"), - CmxIrohTrustBrokerClientError.rejected( - statusCode: 429, - code: "challenge_rate_limited" - ), - .rejected(statusCode: 503, code: "unavailable"), - ]) - func foregroundAvailabilityFailureKeepsLastVerifiedPolicy( - _ failure: CmxIrohTrustBrokerClientError - ) async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let offlineStore = TestSecureCredentialStore() - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - offlinePolicyCache: CmxIrohClientOfflinePolicyCache( - secureStore: offlineStore - ), - now: { fixture.now }, - handlePolicyInvalidation: { - await recorder.recordPolicyInvalidation() - } - ) - try await runtime.start() - await broker.setRegistrationError(failure) - - try await runtime.didBecomeActive() - - #expect(await runtime.snapshot().state == .active) - #expect(await endpoint.observedCloseCallCount() == 0) - #expect(await offlineStore.deleteAllCount() == 0) - #expect(await recorder.observedPolicyInvalidationCount() == 0) - await runtime.stop() - } - - @Test - func signOutWipesLocallyBeforeBestEffortRemoteRevocation() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - revokeError: TestIrohTransportError.unsupported - ) - let recorder = ClientRuntimeTestRecorder() - let offlineStore = TestSecureCredentialStore() - let pendingRevocations = CmxIrohPendingRevocationOutbox( - secureStore: TestSecureCredentialStore() - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - offlinePolicyCache: CmxIrohClientOfflinePolicyCache( - secureStore: offlineStore - ), - now: { fixture.now }, - handleLocalDeactivation: { - let endpointWasClosed = await endpoint.observedCloseCallCount() == 1 - let pendingCount = try? await pendingRevocations.pending( - accountID: fixture.configuration.accountID - ).count - let offlineWasDeactivated = await offlineStore.deleteAllCount() == 1 - await recorder.recordLocalWipe( - endpointWasClosed: endpointWasClosed - && pendingCount == 1 - && offlineWasDeactivated - ) - } - ) - try await runtime.start() - - let preparation = await runtime.deactivateForSignOut() - - #expect(preparation.bindingID == fixture.binding.bindingID) - #expect(preparation.wasPersisted) - #expect(await recorder.observedLocalWipes() == [true]) - #expect(await offlineStore.deleteAllCount() == 1) - #expect(await runtime.snapshot().state == .inactive) - await #expect(throws: TestIrohTransportError.unsupported) { - try await preparation.revoke( - using: broker, - pendingRevocations: pendingRevocations - ) - } - #expect(await broker.observedRevokedBindingIDs() == [fixture.binding.bindingID]) - #expect( - try await pendingRevocations.pending( - accountID: fixture.configuration.accountID - ).count == 1 - ) - #expect(await recorder.observedLocalWipes() == [true]) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func suspendedSignOutPersistenceBlocksRestartUntilLocalTeardownCompletes() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let store = TestControllableSecureCredentialStore() - let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - now: { fixture.now } - ) - try await runtime.start() - await store.suspendNextWrite() - - let signOut = Task { await runtime.deactivateForSignOut() } - await store.waitUntilWriteIsSuspended() - - let signingOut = await runtime.snapshot() - #expect(signingOut.state == .signingOut) - #expect(signingOut.bindingID == fixture.binding.bindingID) - await #expect(throws: CmxIrohClientRuntimeError.alreadyActive) { - try await runtime.start() - } - - await store.resumeSuspendedWrite() - let preparation = await signOut.value - #expect(preparation.wasPersisted) - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func failedSignOutPersistenceClosesEndpointAndQuarantinesLocalState() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let store = TestControllableSecureCredentialStore() - let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) - let offlineStore = TestSecureCredentialStore() - let recorder = ClientRuntimeTestRecorder() - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - offlinePolicyCache: CmxIrohClientOfflinePolicyCache( - secureStore: offlineStore - ), - now: { fixture.now }, - handleLocalDeactivation: { - await recorder.recordLocalWipe(endpointWasClosed: true) - } - ) - try await runtime.start() - await store.failNextWrite() - - let preparation = await runtime.deactivateForSignOut() - - #expect(preparation.bindingID == fixture.binding.bindingID) - #expect(!preparation.wasPersisted) - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await offlineStore.deleteAllCount() == 0) - #expect(await recorder.observedLocalWipes().isEmpty) - let quarantined = await runtime.snapshot() - #expect(quarantined.state == .quarantined) - #expect(quarantined.endpointID == nil) - #expect(quarantined.bindingID == fixture.binding.bindingID) - await #expect(throws: CmxIrohClientRuntimeError.alreadyActive) { - try await runtime.start() - } - - let retried = await runtime.deactivateForSignOut() - #expect(retried.wasPersisted) - #expect(await offlineStore.deleteAllCount() == 1) - #expect(await recorder.observedLocalWipes() == [true]) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func pendingRevocationFailureBlocksRegistrationAndOfflineFallback() async throws { - let fixture = try ClientRuntimeTestFixture() - let store = TestSecureCredentialStore() - let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) - let pending = try CmxIrohPendingRevocation( - accountID: fixture.configuration.accountID, - tag: "older-build", - bindingID: "123e4567-e89b-42d3-a456-426614174099" - ) - try await pendingRevocations.enqueue(pending) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse(), - revokeError: CmxIrohTrustBrokerClientError.connectivity - ) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - offlinePolicyCache: CmxIrohClientOfflinePolicyCache( - secureStore: TestSecureCredentialStore() - ), - now: { fixture.now } - ) - - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { - try await runtime.start() - } - - #expect(await broker.observedRegistrations().isEmpty) - #expect(await broker.observedRevokedBindingIDs() == [pending.bindingID]) - #expect( - try await pendingRevocations.pending( - accountID: fixture.configuration.accountID - ) == [pending] - ) - } - -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientServerEventReceiverTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientServerEventReceiverTests.swift deleted file mode 100644 index 23191c16..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientServerEventReceiverTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohClientServerEventReceiverTests { - @Test - func oneAcceptOwnerRejectsOtherLanesAndDeliversServerEventBytes() async throws { - let identity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ef", count: 32) - ) - let codec = try CmxIrohStreamHeaderCodec() - let artifactReceive = TestIrohReceiveStream( - buffer: try codec.encode( - CmxIrohStreamHeader( - lane: .artifact( - resourceID: CmxIrohResourceID("artifact:unexpected"), - offset: 0 - ) - ) - ) - ) - let payload = Data("framed-event".utf8) - let eventReceive = TestIrohReceiveStream( - buffer: try codec.encode( - CmxIrohStreamHeader(lane: .serverEvents(cursor: nil)) - ) + payload - ) - let connection = TestIrohConnection( - remoteIdentity: identity, - bidirectionalStreams: [], - receiveStreams: [artifactReceive, eventReceive] - ) - let receiver = try CmxIrohClientServerEventReceiver(connection: connection) - - let byteStream = try await receiver.byteStream() - var bytes = byteStream.makeAsyncIterator() - - #expect(try await bytes.next() == payload) - #expect(await artifactReceive.observedStoppedCodes() == [1]) - #expect(await connection.observedReceiveStreamAcceptCount() == 2) - await receiver.close() - #expect(await connection.observedIncomingStreamLimits().first == "0:1") - #expect(await connection.observedIncomingStreamLimits().last == "0:0") - } - - @Test - func aSecondConsumerCannotCreateACompetingAcceptLoop() async throws { - let identity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "12", count: 32) - ) - let blocking = TestBlockingIrohReceiveStream(buffer: Data()) - let connection = TestIrohConnection( - remoteIdentity: identity, - bidirectionalStreams: [], - receiveStreams: [blocking] - ) - let receiver = try CmxIrohClientServerEventReceiver(connection: connection) - - let firstStream = try await receiver.byteStream() - var blockedEvents = await blocking.blockedEvents().makeAsyncIterator() - _ = await blockedEvents.next() - - await #expect( - throws: CmxIrohClientServerEventReceiverError.consumerAlreadyActive - ) { - _ = try await receiver.byteStream() - } - #expect(await connection.observedReceiveStreamAcceptCount() == 1) - _ = firstStream - - await receiver.close() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionLaneTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionLaneTests.swift deleted file mode 100644 index 1cdaa2b0..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionLaneTests.swift +++ /dev/null @@ -1,132 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohClientSessionTests { - @Test - func terminalLaneGetsIndependentHeaderAndPriority() async throws { - let control = controlStream(decision: .accepted) - let terminalSend = TestIrohSendStream() - let terminalStream = CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: terminalSend - ) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream, terminalStream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential, - protocolConfiguration: .testApplicationLanes - ) - try await session.connect() - let lane = CmxIrohLane.terminal( - resourceID: try CmxIrohResourceID("terminal:42"), - cursor: 9 - ) - - _ = try await session.openBidirectionalLane(lane, priority: 50) - - #expect(await terminalSend.observedPriorities() == [50]) - let sent = await terminalSend.observedSentBuffers() - #expect(sent.count == 1) - #expect(try CmxIrohStreamHeaderCodec().decodePrefix(sent[0]).header.lane == lane) - } - - @Test - func productionV1RejectsReservedApplicationLaneBeforeOpeningAStream() async throws { - let control = controlStream(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - try await session.connect() - - await #expect(throws: CmxIrohClientSessionError.applicationLanesUnavailable) { - _ = try await session.openBidirectionalLane( - .artifact( - resourceID: CmxIrohResourceID("artifact:reserved"), - offset: 0 - ), - priority: 10 - ) - } - - #expect(await connection.observedBidirectionalStreamOpenCount() == 1) - } - - @Test - func serverEventReceiverRemovesItsLaneHeaderWithoutDroppingPayload() async throws { - let control = controlStream(decision: .accepted) - let eventHeader = try CmxIrohStreamHeaderCodec().encode( - CmxIrohStreamHeader(lane: .serverEvents(cursor: nil)) - ) - let eventPayload = Data("event-frame".utf8) - let eventReceive = TestIrohReceiveStream(buffer: eventHeader + eventPayload) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream], - receiveStreams: [eventReceive] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - try await session.connect() - - let stream = try await session.serverEventByteStream() - var bytes = stream.makeAsyncIterator() - - #expect(try await bytes.next() == eventPayload) - #expect(await connection.observedIncomingStreamLimits().first == "0:0") - #expect(await connection.observedIncomingStreamLimits().contains("0:1")) - await session.close() - #expect(await connection.observedIncomingStreamLimits().last == "0:0") - } - - @Test - func cancellingConnectCancelsTheUnderlyingIrohDial() async throws { - let endpoint = TestHangingDialEndpoint(localIdentity: localIdentity) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - var started = await endpoint.startedEvents().makeAsyncIterator() - var cancelled = await endpoint.cancelledEvents().makeAsyncIterator() - let connection = Task { try await session.connect() } - _ = await started.next() - - connection.cancel() - - _ = await cancelled.next() - await #expect(throws: CancellationError.self) { - try await connection.value - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolTests.swift deleted file mode 100644 index 195fd1d0..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolTests.swift +++ /dev/null @@ -1,908 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohClientSessionPoolTests { - @Test - func controlAndFeatureLanesReuseOneAdmittedConnection() async throws { - let fixture = try PoolFixture() - let control = fixture.controlStream() - let terminalSend = TestIrohSendStream() - let artifactSend = TestIrohSendStream() - let connection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [ - control, - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: terminalSend - ), - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: artifactSend - ), - ] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [.connection(connection)] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let transport = try factory.makeTransport(for: fixture.request) - - try await transport.connect() - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .terminal( - resourceID: CmxIrohResourceID("terminal:42"), - cursor: 7 - ), - priority: 50 - ) - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .artifact( - resourceID: CmxIrohResourceID("artifact:preview"), - offset: 0 - ), - priority: 10 - ) - - #expect(await endpoint.observedDialedAddresses().count == 1) - #expect(await terminalSend.observedPriorities() == [50]) - #expect(await artifactSend.observedPriorities() == [10]) - #expect(await connection.observedCloseCallCount() == 0) - await #expect(throws: CmxIrohClientSessionError.invalidOutgoingLane) { - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .control, - priority: 0 - ) - } - #expect(await connection.observedCloseCallCount() == 0) - await transport.close() - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func replacementControlOwnerRedialsInsteadOfReusingFramingState() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let first = try factory.makeTransport(for: fixture.request) - try await first.connect() - - await first.close() - let replacement = try factory.makeTransport(for: fixture.request) - try await replacement.connect() - - #expect(await firstConnection.observedCloseCallCount() == 1) - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await secondConnection.observedCloseCallCount() == 0) - await replacement.close() - } - - @Test - func samePeerRouteVariantWaitsForControlHandoffThenRedials() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let first = try factory.makeTransport(for: fixture.request) - let relayHint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.com/", - source: .native, - privacyScope: .publicInternet - ) - let routeVariant = CmxByteTransportRequest( - route: try CmxAttachRoute( - id: "same-peer-with-fresh-hints", - kind: .iroh, - endpoint: .peer( - identity: fixture.remoteIdentity, - pathHints: [relayHint] - ) - ), - expectedPeerDeviceID: fixture.request.expectedPeerDeviceID?.uppercased(), - authorizationMode: .transportAdmission - ) - let second = try factory.makeTransport(for: routeVariant) - - try await first.connect() - let secondConnect = Task { - try await second.connect() - } - - try #require(await waitForControlWaiter(pool, request: routeVariant)) - #expect(await endpoint.observedDialedAddresses().count == 1) - #expect(await firstConnection.observedCloseCallCount() == 0) - await first.close() - try await secondConnect.value - - #expect(await firstConnection.observedCloseCallCount() == 1) - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await secondConnection.observedCloseCallCount() == 0) - await second.close() - } - - @Test - func cancelledControlHandoffDoesNotBlockTheNextOwner() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let replacementConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(replacementConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let first = try factory.makeTransport(for: fixture.request) - let cancelled = try factory.makeTransport(for: fixture.request) - let replacement = try factory.makeTransport(for: fixture.request) - try await first.connect() - - let cancelledConnect = Task { try await cancelled.connect() } - try #require(await waitForControlWaiter(pool, request: fixture.request)) - cancelledConnect.cancel() - await #expect(throws: CancellationError.self) { - try await cancelledConnect.value - } - - await first.close() - try await replacement.connect() - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await replacementConnection.observedCloseCallCount() == 0) - await replacement.close() - } - - @Test - func remoteConnectionCloseEvictsPooledSessionBeforeRedial() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let diagnosticLog = DiagnosticLog(capacity: 8) - let pool = try await fixture.pool( - endpoint: endpoint, - generation: 1, - diagnosticLog: diagnosticLog - ) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let first = try factory.makeTransport(for: fixture.request) - try await first.connect() - for _ in 0 ..< 100 { await Task.yield() } - - await firstConnection.close(errorCode: 99, reason: "peer_closed") - for _ in 0 ..< 100 { await Task.yield() } - - let replacement = try factory.makeTransport(for: fixture.request) - try await replacement.connect() - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await secondConnection.observedCloseCallCount() == 0) - - for _ in 0 ..< 1_000 { - if await diagnosticLog.processedCount() >= 4 { break } - await Task.yield() - } - let events = await diagnosticLog.snapshot().events - #expect(events.map(\.code) == [ - .transportSessionLifecycle, - .transportSessionLifecycle, - .sessionClosed, - .transportSessionLifecycle, - ]) - #expect(events[1].diagnosticSessionLifecycleKind == .remoteClosed) - #expect(events[1].diagnosticSessionPurpose == .foregroundControl) - #expect(events[1].diagnosticSessionID == events[2].diagnosticSessionID) - #expect(events[3].diagnosticSessionID != events[2].diagnosticSessionID) - await replacement.close() - } - - @Test - func knownClosedCachedSessionRedialsWithoutWaitingForClosureWatcher() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - reportsClosureToWaiters: false - ) - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [ - fixture.controlStream(), - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: TestIrohSendStream() - ), - ] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let control = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - try await control.connect() - await firstConnection.close(errorCode: 99, reason: "timed_out") - - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .terminal( - resourceID: CmxIrohResourceID("terminal:known-closed"), - cursor: nil - ), - priority: 50 - ) - - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await secondConnection.observedBidirectionalStreamOpenCount() == 2) - await pool.deactivate() - } - - @Test - func concurrentLaneOpenFailureCoalescesOneAuthenticatedReplacementDial() async throws { - let fixture = try PoolFixture() - let concurrentLaneCount = 8 - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - bidirectionalStreamFailureNumber: 2, - reportsClosureToWaiters: false - ) - let replacementLaneStreams = (0 ..< concurrentLaneCount).map { _ in - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: TestIrohSendStream() - ) - } - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] + replacementLaneStreams - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let control = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - try await control.connect() - - try await withThrowingTaskGroup(of: Void.self) { group in - for index in 0 ..< concurrentLaneCount { - group.addTask { - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .terminal( - resourceID: CmxIrohResourceID("terminal:\(index)"), - cursor: UInt64(index) - ), - priority: Int32(index) - ) - } - } - try await group.waitForAll() - } - - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect( - await secondConnection.observedBidirectionalStreamOpenCount() - == concurrentLaneCount + 1 - ) - await pool.deactivate() - } - - @Test - func laneFailureReplacementRevalidatesEndpointIdentity() async throws { - let fixture = try PoolFixture() - let substitutedIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ef", count: 32) - ) - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - bidirectionalStreamFailureNumber: 2, - reportsClosureToWaiters: false - ) - let substitutedConnection = TestIrohConnection( - remoteIdentity: substitutedIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(substitutedConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let control = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - try await control.connect() - - await #expect(throws: CmxIrohClientSessionError.remoteIdentityMismatch) { - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .terminal( - resourceID: CmxIrohResourceID("terminal:substitution"), - cursor: nil - ), - priority: 0 - ) - } - - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await substitutedConnection.observedCloseCallCount() == 1) - await pool.deactivate() - } - - @Test - func lateOldControlOwnerReleaseDoesNotCloseLaneReplacement() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - reportsClosureToWaiters: false - ) - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [ - fixture.controlStream(), - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: TestIrohSendStream() - ), - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: TestIrohSendStream() - ), - ] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let oldControl = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - try await oldControl.connect() - await firstConnection.close(errorCode: 99, reason: "timed_out") - - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .terminal( - resourceID: CmxIrohResourceID("terminal:first"), - cursor: nil - ), - priority: 0 - ) - await oldControl.close() - _ = try await pool.openBidirectionalLane( - for: fixture.request, - lane: .terminal( - resourceID: CmxIrohResourceID("terminal:second"), - cursor: nil - ), - priority: 0 - ) - - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await secondConnection.observedCloseCallCount() == 0) - await pool.deactivate() - } - - @Test - func endpointGenerationChangeClosesOldSessionBeforeRedial() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let secondConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(secondConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let first = try factory.makeTransport(for: fixture.request) - try await first.connect() - - await pool.activate(runtimeGeneration: 2) - - #expect(await firstConnection.observedCloseCallCount() == 1) - let second = try factory.makeTransport(for: fixture.request) - try await second.connect() - #expect(await endpoint.observedDialedAddresses().count == 2) - #expect(await secondConnection.observedCloseCallCount() == 0) - await pool.deactivate() - } - - @Test - func pooledSessionStartsPublicThenRefreshesAndValidatesLANFallback() async throws { - let fixture = try PoolFixture() - let now = Date() - let publicHint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://use1-1.relay.lawrence.cmux.iroh.link/", - source: .native, - privacyScope: .publicInternet - ) - let profile = try CmxIrohNetworkProfileKey( - source: .lan, - profileID: String(repeating: "b", count: 64) - ) - let privateHint = try CmxIrohPathHint( - kind: .directAddress, - value: "192.168.1.10:50906", - source: .lan, - privacyScope: .localNetwork, - observedAt: now, - expiresAt: now.addingTimeInterval(60), - networkProfile: profile - ) - let authorization = try CmxIrohPrivateFallbackAuthorization( - networkPathSnapshot: CmxIrohNetworkPathSnapshot( - generation: 9, - activeNetworkProfiles: [profile] - ), - pathHints: [privateHint], - admittedAt: now - ) - let base = CmxIrohClientContext( - dialPlan: try testIrohDialPlan(publicPaths: [publicHint]), - credential: fixture.context.credential - ) - let fallback = CmxIrohClientContext( - dialPlan: try testIrohDialPlan( - publicPaths: [publicHint], - privateFallbackPaths: [privateHint] - ), - credential: fixture.context.credential, - privateFallbackAuthorization: authorization - ) - let provider = TestIrohClientContextProvider( - context: base, - fallbackContext: fallback - ) - let connection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .failure(.unsupported), - .connection(connection), - ] - ) - let pool = try await fixture.pool( - endpoint: endpoint, - generation: 1, - contextProvider: provider - ) - let transport = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - - try await transport.connect() - - #expect(await endpoint.observedDialedAddresses().map(\.pathHints) == [ - [publicHint], - [privateHint], - ]) - #expect(await provider.observedFallbackRequestCount() == 1) - #expect(await provider.observedAuthorizations() == [authorization]) - await transport.close() - } - - @Test - func selectedPathLifecycleIsEventDrivenAndCoordinateFree() async throws { - let fixture = try PoolFixture() - let connection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - selectedPath: .privateNetwork - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [.connection(connection)] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let changes = await pool.selectedPathChanges() - var iterator = changes.makeAsyncIterator() - #expect(await iterator.next() != nil) - - let transport = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - try await transport.connect() - - #expect(await iterator.next() != nil) - #expect(await pool.selectedObservedPath() == .privateNetwork) - - await connection.setObservedSelectedPath(.direct) - - #expect(await iterator.next() != nil) - #expect(await pool.selectedObservedPath() == .direct) - - await transport.close() - - #expect(await iterator.next() != nil) - #expect(await pool.selectedObservedPath() == .unavailable) - } - - @Test - func selectedPathDoesNotPublishAnUnestablishedControlSession() async throws { - let fixture = try PoolFixture() - let endpoint = TestHangingDialEndpoint(localIdentity: fixture.localIdentity) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let changes = await pool.selectedPathChanges() - let recorder = SelectedPathChangeRecorder() - let observation = Task { - for await _ in changes { - await recorder.record() - } - } - #expect(await waitForSelectedPathChangeCount(recorder, atLeast: 1)) - - let transport = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - let connection = Task { - try await transport.connect() - } - let started = await endpoint.startedEvents() - var startedIterator = started.makeAsyncIterator() - #expect(await startedIterator.next() != nil) - - let publishedBeforeEstablishment = await waitForSelectedPathChangeCount( - recorder, - atLeast: 2 - ) - #expect(!publishedBeforeEstablishment) - #expect(await pool.selectedObservedPath() == .unavailable) - - connection.cancel() - await pool.deactivate() - _ = try? await connection.value - observation.cancel() - } - - @Test - func selectedPathPrefersTheActiveControlSessionOverANewerBackgroundSession() async throws { - let fixture = try PoolFixture() - let backgroundIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ef", count: 32) - ) - let backgroundRequest = CmxByteTransportRequest( - route: try CmxAttachRoute( - id: "iroh-background-session", - kind: .iroh, - endpoint: .peer(identity: backgroundIdentity, pathHints: []) - ), - expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174031", - authorizationMode: .transportAdmission, - sessionPurpose: .backgroundControl - ) - let controlConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - selectedPath: .direct - ) - let backgroundConnection = TestIrohConnection( - remoteIdentity: backgroundIdentity, - bidirectionalStreams: [fixture.controlStream()], - selectedPath: .relay(url: "https://relay.example.com/") - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(controlConnection), - .connection(backgroundConnection), - ] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let control = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: fixture.request) - let background = try CmxIrohByteTransportFactory(sessionPool: pool) - .makeTransport(for: backgroundRequest) - - try await control.connect() - #expect(await pool.selectedObservedPath() == .direct) - - try await background.connect() - - #expect(await pool.selectedObservedPath() == .direct) - await background.close() - await control.close() - } - - @Test - func ownerReleaseExplainsTheExactUnavailableRelayPrivatePathCycle() async throws { - let fixture = try PoolFixture() - let firstConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - selectedPath: .privateNetwork - ) - let replacementConnection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - selectedPath: .relay(url: "https://relay.example.com/") - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [ - .connection(firstConnection), - .connection(replacementConnection), - ] - ) - let diagnosticLog = DiagnosticLog(capacity: 16) - let pool = try await fixture.pool( - endpoint: endpoint, - generation: 1, - diagnosticLog: diagnosticLog - ) - let changes = await pool.selectedPathChanges() - var iterator = changes.makeAsyncIterator() - #expect(await iterator.next() != nil) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - - let first = try factory.makeTransport(for: fixture.request) - try await first.connect() - #expect(await iterator.next() != nil) - #expect(await pool.selectedObservedPath() == .privateNetwork) - - await first.close() - #expect(await iterator.next() != nil) - let unavailable = await pool.selectedObservedPath() - - let replacement = try factory.makeTransport(for: fixture.request) - try await replacement.connect() - #expect(await iterator.next() != nil) - let relay = await pool.selectedObservedPath() - - await replacementConnection.setObservedSelectedPath(.privateNetwork) - #expect(await iterator.next() != nil) - let privateNetwork = await pool.selectedObservedPath() - - #expect([unavailable, relay, privateNetwork] == [ - .unavailable, - .relay(url: "https://relay.example.com/"), - .privateNetwork, - ]) - - for _ in 0 ..< 1_000 { - if await diagnosticLog.processedCount() >= 4 { break } - await Task.yield() - } - let events = await diagnosticLog.snapshot().events - #expect(events.map(\.code) == [ - .transportSessionLifecycle, - .transportSessionLifecycle, - .sessionClosed, - .transportSessionLifecycle, - ]) - #expect(events.map(\.a) == [ - DiagnosticSessionLifecycleKind.established.rawValue, - DiagnosticSessionLifecycleKind.controlOwnerReleased.rawValue, - DiagnosticTransportKind.iroh.rawValue, - DiagnosticSessionLifecycleKind.established.rawValue, - ]) - #expect(events[0].b == Int(CmxTransportSessionPurpose.foregroundControl.rawValue)) - #expect(events[1].b == Int(CmxTransportSessionPurpose.foregroundControl.rawValue)) - #expect(events[2].b == DiagnosticFailureKind.none.rawValue) - #expect(events[0].c == events[1].c) - #expect(events[1].c == events[2].c) - #expect(events[3].c != events[2].c) - - await replacement.close() - } - - @Test - func mixedCaseBackgroundAliasCannotTearDownTheForegroundControlOwner() async throws { - let fixture = try PoolFixture() - let connection = TestIrohConnection( - remoteIdentity: fixture.remoteIdentity, - bidirectionalStreams: [fixture.controlStream()], - selectedPath: .privateNetwork - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: fixture.localIdentity, - dialResults: [.connection(connection)] - ) - let pool = try await fixture.pool(endpoint: endpoint, generation: 1) - let factory = CmxIrohByteTransportFactory(sessionPool: pool) - let foreground = try factory.makeTransport(for: fixture.request) - let backgroundRequest = CmxByteTransportRequest( - route: fixture.request.route, - expectedPeerDeviceID: fixture.request.expectedPeerDeviceID?.uppercased(), - authorizationMode: .transportAdmission, - sessionPurpose: .backgroundControl - ) - let background = try factory.makeTransport(for: backgroundRequest) - - try await foreground.connect() - let backgroundConnect = Task { try await background.connect() } - try #require(await waitForControlWaiter(pool, request: backgroundRequest)) - backgroundConnect.cancel() - await #expect(throws: CancellationError.self) { - try await backgroundConnect.value - } - - #expect(await pool.selectedObservedPath() == .privateNetwork) - #expect(await connection.observedCloseCallCount() == 0) - #expect(await endpoint.observedDialedAddresses().count == 1) - await foreground.close() - } -} - -private func waitForControlWaiter( - _ pool: CmxIrohClientSessionPool, - request: CmxByteTransportRequest -) async -> Bool { - for _ in 0 ..< 1_000 { - if await pool.controlWaiterCount(for: request) == 1 { return true } - await Task.yield() - } - return false -} - -private actor SelectedPathChangeRecorder { - private var count = 0 - - func record() { - count += 1 - } - - func observedCount() -> Int { - count - } -} - -private func waitForSelectedPathChangeCount( - _ recorder: SelectedPathChangeRecorder, - atLeast expectedCount: Int -) async -> Bool { - for _ in 0 ..< 1_000 { - if await recorder.observedCount() >= expectedCount { return true } - await Task.yield() - } - return false -} - -private struct PoolFixture { - let localIdentity: CmxIrohPeerIdentity - let remoteIdentity: CmxIrohPeerIdentity - let request: CmxByteTransportRequest - let context: CmxIrohClientContext - - init() throws { - localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ab", count: 32) - ) - remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "cd", count: 32) - ) - request = CmxByteTransportRequest( - route: try CmxAttachRoute( - id: "iroh-pool", - kind: .iroh, - endpoint: .peer(identity: remoteIdentity, pathHints: []) - ), - expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174030", - authorizationMode: .transportAdmission - ) - context = CmxIrohClientContext( - dialPlan: try testIrohDialPlan(), - credential: try .pairGrant("e30.e30.AA") - ) - } - - func pool( - endpoint: any CmxIrohEndpoint, - generation: UInt64, - contextProvider: (any CmxIrohClientContextProvider)? = nil, - diagnosticLog: DiagnosticLog? = nil - ) async throws -> CmxIrohClientSessionPool { - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: configuration - ) - _ = try await supervisor.activate() - let pool = CmxIrohClientSessionPool( - supervisor: supervisor, - contextProvider: contextProvider - ?? TestIrohClientContextProvider(context: context), - protocolConfiguration: .testApplicationLanes, - diagnosticLog: diagnosticLog - ) - await pool.activate(runtimeGeneration: generation) - return pool - } - - func controlStream() -> CmxIrohBidirectionalStream { - let admissionCodec = CmxIrohAdmissionAckCodec() - return CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream( - buffer: admissionCodec.encode(.accepted) - + admissionCodec.encodeFrame(.serverReady) - ), - sendStream: TestIrohSendStream() - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests+PrivateFallbackSupport.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests+PrivateFallbackSupport.swift deleted file mode 100644 index 2b56141a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests+PrivateFallbackSupport.swift +++ /dev/null @@ -1,42 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -extension CmxIrohClientSessionTests { - func privateFallbackAuthorization( - for hints: [CmxIrohPathHint] - ) throws -> CmxIrohPrivateFallbackAuthorization { - let profiles = Set(hints.compactMap(\.networkProfile)) - let admittedAt = hints.compactMap(\.observedAt).min()?.addingTimeInterval(1) ?? Date() - return try CmxIrohPrivateFallbackAuthorization( - networkPathSnapshot: CmxIrohNetworkPathSnapshot( - generation: 7, - activeNetworkProfiles: profiles - ), - pathHints: hints, - admittedAt: admittedAt - ) - } -} - -actor TestPrivateFallbackValidator: CmxIrohPrivateFallbackValidating { - private let error: CmxIrohPrivateFallbackValidationError? - private var authorizations: [CmxIrohPrivateFallbackAuthorization] = [] - - init(error: CmxIrohPrivateFallbackValidationError? = nil) { - self.error = error - } - - func validatePrivateFallback( - _ authorization: CmxIrohPrivateFallbackAuthorization - ) throws { - authorizations.append(authorization) - if let error { - throw error - } - } - - func observedAuthorizations() -> [CmxIrohPrivateFallbackAuthorization] { - authorizations - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests.swift deleted file mode 100644 index 25c20daa..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests.swift +++ /dev/null @@ -1,551 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohClientSessionTests { - let localIdentity: CmxIrohPeerIdentity - let remoteIdentity: CmxIrohPeerIdentity - let credential: CmxIrohAdmissionCredential - - init() throws { - localIdentity = try CmxIrohPeerIdentity(endpointID: String(repeating: "ab", count: 32)) - remoteIdentity = try CmxIrohPeerIdentity(endpointID: String(repeating: "cd", count: 32)) - credential = try .pairGrant("e30.e30.AA") - } - - @Test - func publicDialAdmitsControlAndPreservesFollowingRPCBytes() async throws { - let events = TestIrohEventRecorder() - let control = controlStream( - decision: .accepted, - trailingBytes: Data("rpc".utf8), - eventRecorder: events - ) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream], - eventRecorder: events - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let publicHint = try publicRelayHint() - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(publicPaths: [publicHint]), - credential: credential - ) - - try await session.connect() - - // Admission must not grant peer-initiated stream credit before a - // production owner is installed. The dedicated server-events receiver - // raises only the one unidirectional credit it owns. - #expect(await connection.observedIncomingStreamLimits() == ["0:0"]) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedNatTraversalActivationCount() == 1) - #expect(await connection.observedBidirectionalStreamOpenCount() == 1) - let dialed = await endpoint.observedDialedAddresses() - #expect(dialed == [CmxIrohEndpointAddress(identity: remoteIdentity, pathHints: [publicHint])]) - let sent = await control.send.observedSentBuffers() - let encodedHeader = try #require(sent.first) - let clientReady = try #require(sent.dropFirst().first) - #expect(sent.count == 2) - let decodedHeader = try CmxIrohStreamHeaderCodec().decodePrefix(encodedHeader).header - let expectedHeader = try CmxIrohStreamHeader(lane: .control, credential: credential) - #expect(decodedHeader == expectedHeader) - #expect(clientReady == admissionFrame(status: 2)) - #expect(await events.observedEvents() == [ - "connection.limits:0:0", - "connection.openBidirectionalStream", - "control.send", - "connection.authorizeNatTraversal", - "control.send", - ]) - #expect(try await session.receiveControl() == Data("rpc".utf8)) - } - - @Test - func repeatedConnectDoesNotRepeatNatTraversalAuthorization() async throws { - let control = controlStream(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - try await session.connect() - try await session.connect() - - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedNatTraversalActivationCount() == 1) - #expect(await connection.observedBidirectionalStreamOpenCount() == 1) - #expect(await endpoint.observedDialedAddresses().count == 1) - } - - @Test - func relayOnlyAdmissionCompletesBarrierWithoutAuthorizingNatTraversal() async throws { - let events = TestIrohEventRecorder() - let control = controlStream( - decision: .accepted, - acceptedFrame: .acceptedRelayOnly, - trailingBytes: Data("rpc".utf8), - eventRecorder: events - ) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream], - eventRecorder: events - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - try await session.connect() - - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedNatTraversalActivationCount() == 0) - #expect(await control.send.observedSentBuffers().count == 2) - #expect(await events.observedEvents() == [ - "connection.limits:0:0", - "connection.openBidirectionalStream", - "control.send", - "control.send", - ]) - #expect(try await session.receiveControl() == Data("rpc".utf8)) - } - - @Test - func privateHintsAreAttemptedOnlyAfterPublicFailure() async throws { - let control = controlStream(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [ - .failure(.unsupported), - .connection(connection), - ] - ) - let publicHint = try publicRelayHint() - let privateHint = try tailscaleHint() - let authorization = try privateFallbackAuthorization(for: [privateHint]) - let validator = TestPrivateFallbackValidator() - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan( - publicPaths: [publicHint], - privateFallbackPaths: [privateHint] - ), - credential: credential, - privateFallbackAuthorization: authorization, - privateFallbackValidator: validator - ) - - try await session.connect() - - let dialed = await endpoint.observedDialedAddresses() - #expect(dialed.map(\.pathHints) == [[publicHint], [privateHint]]) - #expect(await validator.observedAuthorizations() == [authorization]) - } - - @Test - func emptyPublicPlanFailsTypedWithoutCallingTheNativeDialer() async throws { - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.failure(.unsupported)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(publicPaths: [], privateFallbackPaths: []), - credential: credential - ) - - await #expect(throws: CmxIrohRegistryContextError.dialPlanUnavailable) { - try await session.connect() - } - #expect(await endpoint.observedDialedAddresses().isEmpty) - } - - @Test - func emptyPublicPlanResolvesAndValidatesPrivateFallbackBeforeDialing() async throws { - let control = controlStream(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let privateHint = try tailscaleHint() - let authorization = try privateFallbackAuthorization(for: [privateHint]) - let validator = TestPrivateFallbackValidator() - let fallbackContext = CmxIrohClientContext( - dialPlan: try testIrohDialPlan( - publicPaths: [], - privateFallbackPaths: [privateHint] - ), - credential: credential, - privateFallbackAuthorization: authorization - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(publicPaths: [], privateFallbackPaths: []), - credential: credential, - privateFallbackValidator: validator, - privateFallbackContextProvider: { fallbackContext } - ) - - try await session.connect() - - #expect(await endpoint.observedDialedAddresses().map(\.pathHints) == [[privateHint]]) - #expect(await validator.observedAuthorizations() == [authorization]) - } - - @Test - func privateFallbackIsNotDialedWhenItsNetworkStateCannotBeRevalidated() async throws { - let control = controlStream(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [ - .failure(.unsupported), - .connection(connection), - ] - ) - let publicHint = try publicRelayHint() - let privateHint = try tailscaleHint() - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan( - publicPaths: [publicHint], - privateFallbackPaths: [privateHint] - ), - credential: credential - ) - - await #expect(throws: CmxIrohPrivateFallbackValidationError.unavailable) { - try await session.connect() - } - - let dialed = await endpoint.observedDialedAddresses() - #expect(dialed.map(\.pathHints) == [[publicHint]]) - } - - @Test - func failedPrivateFallbackRevalidationPreventsItsDial() async throws { - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [ - .failure(.unsupported), - .failure(.unsupported), - ] - ) - let publicHint = try publicRelayHint() - let privateHint = try tailscaleHint() - let authorization = try privateFallbackAuthorization(for: [privateHint]) - let validator = TestPrivateFallbackValidator(error: .generationChanged) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan( - publicPaths: [publicHint], - privateFallbackPaths: [privateHint] - ), - credential: credential, - privateFallbackAuthorization: authorization, - privateFallbackValidator: validator - ) - - await #expect(throws: CmxIrohPrivateFallbackValidationError.generationChanged) { - try await session.connect() - } - - let dialed = await endpoint.observedDialedAddresses() - #expect(dialed.map(\.pathHints) == [[publicHint]]) - #expect(await validator.observedAuthorizations() == [authorization]) - } - - @Test - func mismatchedTLSIdentityClosesBeforeOpeningAControlStream() async throws { - let attackerIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ef", count: 32) - ) - let connection = TestIrohConnection( - remoteIdentity: attackerIdentity, - bidirectionalStreams: [] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - await #expect(throws: CmxIrohClientSessionError.remoteIdentityMismatch) { - try await session.connect() - } - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func deniedAdmissionClosesTheWholeConnection() async throws { - let control = controlStream(decision: .denied(code: 7)) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - await #expect(throws: CmxIrohClientSessionError.admissionDenied(code: 7)) { - try await session.connect() - } - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func deniedAdmissionNeverCreatesAPrivateFallbackConnection() async throws { - let denied = controlStream(decision: .denied(code: 7)) - let deniedConnection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [denied.stream] - ) - let replacement = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [controlStream(decision: .accepted).stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [ - .connection(deniedConnection), - .connection(replacement), - ] - ) - let publicHint = try publicRelayHint() - let privateHint = try tailscaleHint() - let authorization = try privateFallbackAuthorization(for: [privateHint]) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan( - publicPaths: [publicHint], - privateFallbackPaths: [privateHint] - ), - credential: credential, - privateFallbackAuthorization: authorization, - privateFallbackValidator: TestPrivateFallbackValidator() - ) - - await #expect(throws: CmxIrohClientSessionError.admissionDenied(code: 7)) { - try await session.connect() - } - - #expect(await endpoint.observedDialedAddresses().map(\.pathHints) == [[publicHint]]) - #expect(await deniedConnection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await replacement.observedBidirectionalStreamOpenCount() == 0) - } - - @Test - func natTraversalAuthorizationFailureSendsNoReadyAckAndCloses() async throws { - let control = controlStream(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream], - natTraversalAuthorizationError: .natTraversalAuthorizationFailed - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - await #expect(throws: TestIrohTransportError.natTraversalAuthorizationFailed) { - try await session.connect() - } - - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedNatTraversalActivationCount() == 0) - #expect(await control.send.observedSentBuffers().count == 1) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func missingServerReadyFailsBeforeAnyApplicationLaneCanOpen() async throws { - let control = controlStream( - decision: .accepted, - serverConfirmationStatus: nil - ) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - await #expect(throws: CmxIrohClientSessionError.unexpectedEndOfStream) { - try await session.connect() - } - await #expect(throws: CmxIrohClientSessionError.notConnected) { - _ = try await session.openBidirectionalLane( - .artifact(resourceID: CmxIrohResourceID("artifact:blocked"), offset: 0), - priority: 1 - ) - } - - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedBidirectionalStreamOpenCount() == 1) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func roleInvalidServerConfirmationFailsClosed() async throws { - let control = controlStream( - decision: .accepted, - serverConfirmationStatus: 2 - ) - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [control.stream] - ) - let endpoint = TestDialingIrohEndpoint( - localIdentity: localIdentity, - dialResults: [.connection(connection)] - ) - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: remoteIdentity, - dialPlan: try testIrohDialPlan(), - credential: credential - ) - - await #expect(throws: CmxIrohClientSessionError.invalidAdmissionFrame) { - try await session.connect() - } - - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedCloseCallCount() == 1) - } - - func controlStream( - decision: CmxIrohAdmissionDecision, - acceptedFrame: CmxIrohAdmissionFrame = .acceptedPendingNatTraversal, - trailingBytes: Data = Data(), - serverConfirmationStatus: UInt8? = 3, - eventRecorder: TestIrohEventRecorder? = nil - ) -> (stream: CmxIrohBidirectionalStream, send: TestIrohSendStream) { - let finalFrame = if decision == .accepted, let serverConfirmationStatus { - admissionFrame(status: serverConfirmationStatus) - } else { - Data() - } - let initialFrame = switch decision { - case .accepted: - CmxIrohAdmissionAckCodec().encodeFrame(acceptedFrame) - case .denied: - CmxIrohAdmissionAckCodec().encode(decision) - } - let receive = TestIrohReceiveStream( - buffer: initialFrame + finalFrame + trailingBytes - ) - let send = TestIrohSendStream( - eventRecorder: eventRecorder, - eventName: "control.send" - ) - return ( - CmxIrohBidirectionalStream(receiveStream: receive, sendStream: send), - send - ) - } - - func admissionFrame(status: UInt8, code: UInt16 = 0) -> Data { - var frame = Data("CMXA".utf8) - frame.append(1) - frame.append(status) - let bigEndian = code.bigEndian - withUnsafeBytes(of: bigEndian) { frame.append(contentsOf: $0) } - return frame - } - - func publicRelayHint() throws -> CmxIrohPathHint { - try CmxIrohPathHint( - kind: .relayURL, - value: "https://use1-1.relay.lawrence.cmux.iroh.link/", - source: .native, - privacyScope: .publicInternet - ) - } - - func tailscaleHint() throws -> CmxIrohPathHint { - let observedAt = Date(timeIntervalSince1970: 1_000) - return try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.0.8:4242", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: observedAt, - expiresAt: observedAt.addingTimeInterval(30 * 60), - networkProfile: CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: String(repeating: "a", count: 64) - ) - ) - } - -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift deleted file mode 100644 index d748e856..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohConfigurationTests.swift +++ /dev/null @@ -1,141 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohConfigurationTests { - private let now = Date(timeIntervalSince1970: 1_000) - - @Test - func endpointSecretRequiresExactlyThirtyTwoBytes() { - #expect(throws: CmxIrohSecretKeyError.invalidByteCount(31)) { - try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 31)) - } - #expect(throws: CmxIrohSecretKeyError.invalidByteCount(33)) { - try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 33)) - } - } - - @Test - func relayCredentialRequiresCanonicalURLTokenAndFutureRefresh() throws { - #expect(throws: CmxIrohRelayConfigurationError.invalidURL) { - try relay(url: "http://relay.example/", token: "aaaa") - } - #expect(throws: CmxIrohRelayConfigurationError.invalidURL) { - try relay(url: "https://relay.example", token: "aaaa") - } - #expect(throws: CmxIrohRelayConfigurationError.invalidToken) { - try relay(url: "https://relay.example/", token: "upperCASE") - } - #expect( - try relay(url: "https://relay.example/", token: "aB_-.cD-_.eF_-").token - == "aB_-.cD-_.eF_-" - ) - #expect(throws: CmxIrohRelayConfigurationError.invalidLifetime) { - try CmxIrohRelayConfiguration( - url: "https://relay.example/", - token: "aaaa", - expiresAt: now.addingTimeInterval(10), - refreshAfter: now, - now: now - ) - } - } - - @Test - func endpointConfigurationRejectsUnmanagedAndDuplicateRelays() throws { - let relay = try relay(url: "https://relay.example/", token: "aaaa") - let secret = try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 32)) - - #expect(throws: CmxIrohEndpointConfigurationError.unmanagedRelayURL(relay.url)) { - try CmxIrohEndpointConfiguration( - secretKey: secret, - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [relay] - ) - } - #expect(throws: CmxIrohEndpointConfigurationError.duplicateRelayURL(relay.url)) { - try CmxIrohEndpointConfiguration( - secretKey: secret, - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [relay.url], - relays: [relay, relay] - ) - } - } - - @Test - func customEndpointProfileExcludesManagedFallbackAndPreservesDirectPaths() throws { - let custom = try CmxIrohCustomRelayProfile( - relays: [ - CmxIrohCustomRelay( - url: "https://private.example.net:8443/", - authenticationToken: "private-token" - ), - ] - ) - let profile = CmxIrohEndpointRelayProfile(customProfile: custom) - let configuration = CmxIrohEndpointConfiguration( - secretKey: try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - relayProfile: profile - ) - - #expect(configuration.relayProfile.allowedRelayURLs == [custom.relays[0].url]) - #expect(configuration.managedRelayURLs.isEmpty) - #expect(configuration.relays.isEmpty) - } - - @Test - func bindPolicyDefaultsToEphemeralAndSerializesNumericStableAddresses() throws { - let secret = try CmxIrohSecretKey(bytes: Data(repeating: 0, count: 32)) - let defaultConfiguration = try CmxIrohEndpointConfiguration( - secretKey: secret, - alpns: [], - managedRelayURLs: [], - relays: [] - ) - #expect(defaultConfiguration.bindPolicy == .ephemeral) - #expect(defaultConfiguration.bindPolicy.socketAddress == nil) - - let ipv4 = try CmxIrohBindAddress(ipAddress: "0.0.0.0", port: 49_152) - let ipv6 = try CmxIrohBindAddress(ipAddress: "::", port: 49_153) - #expect(CmxIrohEndpointBindPolicy.required(ipv4).socketAddress == "0.0.0.0:49152") - #expect(CmxIrohEndpointBindPolicy.required(ipv6).socketAddress == "[::]:49153") - #expect(CmxIrohEndpointBindPolicy.preferred(ipv4).socketAddress == "0.0.0.0:49152") - #expect(CmxIrohEndpointBindPolicy.preferred(ipv4).allowsEphemeralFallback) - #expect(!CmxIrohEndpointBindPolicy.required(ipv4).allowsEphemeralFallback) - } - - @Test - func stableBindAddressRejectsEphemeralPortsAndNonNumericHosts() { - #expect(throws: CmxIrohBindAddressError.zeroPort) { - try CmxIrohBindAddress(ipAddress: "0.0.0.0", port: 0) - } - for value in [ - "mac.tailnet.ts.net", - "[::]", - "fe80::1%en0", - "127.0.0.1\0ignored", - " 127.0.0.1", - ] { - #expect(throws: CmxIrohBindAddressError.invalidIPAddress) { - try CmxIrohBindAddress(ipAddress: value, port: 49_152) - } - } - } - - private func relay( - url: String, - token: String - ) throws -> CmxIrohRelayConfiguration { - try CmxIrohRelayConfiguration( - url: url, - token: token, - expiresAt: now.addingTimeInterval(24 * 60 * 60), - refreshAfter: now.addingTimeInterval(12 * 60 * 60), - now: now - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathProviderTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathProviderTests.swift deleted file mode 100644 index 25f0e853..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathProviderTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohRegistryContextProviderTests { - @Test - func customPrivateAddressesUseAuthenticatedMacPortsAndIdentity() async throws { - let fixture = try RegistryFixture() - let profile = try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: opaqueProfileID("custom-private") - ) - let recorder = try TestCustomPrivateFallbackRecorder( - paths: ["10.0.0.8", "fd00::8"].map { - try CmxIrohCustomPrivatePathBootstrap( - address: CmxIrohCustomPrivateAddress($0), - networkProfile: profile - ) - } - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - targetDirectPorts: ["ipv4": 50_909, "ipv6": 54_750] - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 7, - activeNetworkProfiles: [profile] - ) - }, - customPrivateFallback: { deviceID in - await recorder.paths(for: deviceID) - }, - now: { fixture.now } - ) - - let context = try await provider.context(for: fixture.request(hints: [])) - - #expect(await recorder.requestedDeviceIDs() == [fixture.acceptor.deviceID]) - #expect(context.dialPlan.privateFallbackPaths.map(\.value) == [ - "10.0.0.8:50909", - "[fd00::8]:54750", - ]) - #expect(context.dialPlan.privateFallbackPaths.allSatisfy { - $0.source == .customVPN - && $0.privacyScope == .privateNetwork - && $0.networkProfile == profile - }) - let authorization = try #require(context.privateFallbackAuthorization) - #expect(authorization.networkPathSnapshot.generation == 7) - - await #expect(throws: CmxIrohRegistryContextError.targetDeviceMismatch) { - try await provider.context(for: fixture.request( - hints: [], - expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174099" - )) - } - #expect(await recorder.requestedDeviceIDs() == [fixture.acceptor.deviceID]) - } - - @Test - func customPrivateAddressCannotGuessMissingOrStaleBrokerPort() async throws { - let fixture = try RegistryFixture() - let profile = try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: opaqueProfileID("custom-private") - ) - let path = try CmxIrohCustomPrivatePathBootstrap( - address: CmxIrohCustomPrivateAddress("10.0.0.8"), - networkProfile: profile - ) - let stale = fixture.now.addingTimeInterval( - -(CmxIrohPathHint.maximumPrivateHintTTL + 1) - ) - let cases: [(String, [String: Int]?, Date?)] = [ - ("missing", nil, nil), - ("wrong family", ["ipv6": 54_750], nil), - ("stale", ["ipv4": 50_909], stale), - ] - - for (name, directPorts, lastSeenAt) in cases { - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - targetDirectPorts: directPorts, - targetLastSeenAt: lastSeenAt - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 7, - activeNetworkProfiles: [profile] - ) - }, - customPrivateFallback: { _ in [path] }, - now: { fixture.now } - ) - - let context = try await provider.context(for: fixture.request(hints: [])) - - #expect(context.dialPlan.privateFallbackPaths.isEmpty, Comment(rawValue: name)) - #expect(context.privateFallbackAuthorization == nil, Comment(rawValue: name)) - } - } - - @Test - func customPrivateConfigurationGenerationChangeRevokesAuthorization() async throws { - let fixture = try RegistryFixture() - let profile = try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: opaqueProfileID("custom-private") - ) - let path = try CmxIrohCustomPrivatePathBootstrap( - address: CmxIrohCustomPrivateAddress("10.0.0.8"), - networkProfile: profile - ) - let customState = TestCustomPrivateSnapshotState( - CmxIrohCustomPrivatePathSnapshot( - generation: 2, - configurations: [], - activeNetworkProfiles: [profile] - ) - ) - let composer = CmxIrohNetworkPathSnapshotComposer() - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - targetDirectPorts: ["ipv4": 50_909] - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - await composer.compose( - platform: CmxIrohNetworkPathSnapshot( - generation: 11, - activeNetworkProfiles: [] - ), - custom: await customState.snapshot() - ) - }, - customPrivateFallback: { _ in [path] }, - now: { fixture.now } - ) - let context = try await provider.context(for: fixture.request(hints: [])) - let authorization = try #require(context.privateFallbackAuthorization) - - await customState.set(CmxIrohCustomPrivatePathSnapshot( - generation: 3, - configurations: [], - activeNetworkProfiles: [] - )) - - await #expect(throws: CmxIrohPrivateFallbackValidationError.generationChanged) { - try await provider.validatePrivateFallback(authorization) - } - } -} - -private actor TestCustomPrivateFallbackRecorder { - private let configuredPaths: [CmxIrohCustomPrivatePathBootstrap] - private var deviceIDs: [String] = [] - - init(paths: [CmxIrohCustomPrivatePathBootstrap]) throws { - configuredPaths = paths - } - - func paths(for deviceID: String) -> [CmxIrohCustomPrivatePathBootstrap] { - deviceIDs.append(deviceID) - return configuredPaths - } - - func requestedDeviceIDs() -> [String] { deviceIDs } -} - -private actor TestCustomPrivateSnapshotState { - private var value: CmxIrohCustomPrivatePathSnapshot - - init(_ value: CmxIrohCustomPrivatePathSnapshot) { - self.value = value - } - - func snapshot() -> CmxIrohCustomPrivatePathSnapshot { value } - - func set(_ value: CmxIrohCustomPrivatePathSnapshot) { - self.value = value - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift deleted file mode 100644 index 412ef6b5..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomPrivatePathStoreTests.swift +++ /dev/null @@ -1,172 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohCustomPrivatePathStoreTests { - private let macA = "123e4567-e89b-42d3-a456-426614174004" - private let macB = "123e4567-e89b-42d3-a456-426614174005" - - @Test - func preferencesRemainDeviceLocalAccountScopedAndExactMacScoped() async throws { - let installState = CustomPrivatePathMemoryStore() - let store = CmxIrohCustomPrivatePathStore(store: installState) - let saved = try await store.upsert( - CmxIrohCustomPrivatePathDraft( - macDeviceID: macA.uppercased(), - macDisplayName: " Work Mac ", - addresses: ["10.0.0.8", "10.0.0.8", "fd00::8"], - isEnabled: true - ), - accountID: "account-a" - ) - - #expect(saved.generation == 2) - #expect(saved.configurations.count == 1) - #expect(saved.configurations[0].macDeviceID == macA) - #expect(saved.configurations[0].macDisplayName == "Work Mac") - #expect(saved.configurations[0].addresses.map(\.value) == ["10.0.0.8", "fd00::8"]) - #expect(saved.activeNetworkProfiles.count == 1) - #expect(await store.enabledPaths( - forMacDeviceID: macA, - accountID: "account-a" - ).map(\.address.value) == ["10.0.0.8", "fd00::8"]) - #expect(await store.enabledPaths( - forMacDeviceID: macB, - accountID: "account-a" - ).isEmpty) - #expect(await store.enabledPaths( - forMacDeviceID: macA, - accountID: "account-b" - ).isEmpty) - - let restored = CmxIrohCustomPrivatePathStore(store: installState) - #expect(try await restored.snapshot(accountID: "account-a") == saved) - } - - @Test - func disabledAndRemovedPreferencesRevokeProfileAuthority() async throws { - let store = CmxIrohCustomPrivatePathStore( - store: CustomPrivatePathMemoryStore() - ) - _ = try await store.upsert( - CmxIrohCustomPrivatePathDraft( - macDeviceID: macA, - macDisplayName: "Work Mac", - addresses: ["10.0.0.8"], - isEnabled: false - ), - accountID: "account-a" - ) - let disabled = try await store.snapshot(accountID: "account-a") - #expect(disabled.activeNetworkProfiles.isEmpty) - #expect(await store.enabledPaths( - forMacDeviceID: macA, - accountID: "account-a" - ).isEmpty) - - let removed = try await store.remove( - macDeviceID: macA, - accountID: "account-a" - ) - #expect(removed.generation == disabled.generation + 1) - #expect(removed.configurations.isEmpty) - #expect(removed.activeNetworkProfiles.isEmpty) - } - - @Test - func invalidInputCannotEnterPersistence() async { - let store = CmxIrohCustomPrivatePathStore( - store: CustomPrivatePathMemoryStore() - ) - for addresses in [ - ["private.example.com"], - ["127.0.0.1"], - ["10.0.0.8:49152"], - ] { - await #expect(throws: CmxIrohCustomPrivateAddressError.invalidAddress) { - _ = try await store.upsert( - CmxIrohCustomPrivatePathDraft( - macDeviceID: macA, - macDisplayName: "Work Mac", - addresses: addresses, - isEnabled: true - ), - accountID: "account-a" - ) - } - } - } - - @Test - func malformedDeviceLocalStateFailsClosed() async throws { - let installState = CustomPrivatePathMemoryStore() - let scope = try CmxIrohRelayStorageScope.account( - "account-a", - prefix: "custom-private-paths" - ) - installState.set("not-base64", forKey: scope) - let store = CmxIrohCustomPrivatePathStore(store: installState) - - #expect(await store.availableSnapshot(accountID: "account-a") == .unavailable) - #expect(await store.enabledPaths( - forMacDeviceID: macA, - accountID: "account-a" - ).isEmpty) - } - - @Test - func composerChangesGenerationWhenEitherAuthorityChanges() async throws { - let platformProfile = try CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: opaqueProfileID("platform") - ) - let customProfile = try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: opaqueProfileID("custom") - ) - let composer = CmxIrohNetworkPathSnapshotComposer() - let platform = CmxIrohNetworkPathSnapshot( - generation: 8, - activeNetworkProfiles: [platformProfile] - ) - let custom = CmxIrohCustomPrivatePathSnapshot( - generation: 2, - configurations: [], - activeNetworkProfiles: [customProfile] - ) - - let first = await composer.compose(platform: platform, custom: custom) - let same = await composer.compose(platform: platform, custom: custom) - let changed = await composer.compose( - platform: platform, - custom: CmxIrohCustomPrivatePathSnapshot( - generation: 3, - configurations: [], - activeNetworkProfiles: [] - ) - ) - - #expect(first.generation == same.generation) - #expect(first.activeNetworkProfiles == [platformProfile, customProfile]) - #expect(changed.generation == first.generation + 1) - #expect(changed.activeNetworkProfiles == [platformProfile]) - } -} - -private final class CustomPrivatePathMemoryStore: - CmxIrohInstallStateStoring, - @unchecked Sendable -{ - private let lock = NSLock() - private var values: [String: String] = [:] - - func string(forKey key: String) -> String? { - lock.withLock { values[key] } - } - - func set(_ value: String?, forKey key: String) { - lock.withLock { values[key] = value } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift deleted file mode 100644 index 4a23d62c..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveEnvironment.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -enum CmxIrohCustomRelayLiveEnvironment { - enum EnvironmentError: Error { - case invalid(String) - case missing(String) - } - - static let environment = ProcessInfo.processInfo.environment - - static var isEnabled: Bool { - environment["CMUX_IROH_CUSTOM_RELAY_LIVE"] == "1" - } - - static var hasNoTokenRelay: Bool { - environment["CMUX_IROH_CUSTOM_RELAY_NO_TOKEN_URL"]?.isEmpty == false - } - - static var hasStaticTokenRelay: Bool { - environment["CMUX_IROH_CUSTOM_RELAY_STATIC_URL"]?.isEmpty == false - && environment["CMUX_IROH_CUSTOM_RELAY_STATIC_TOKEN"]?.isEmpty == false - } - - static var hasEndpointBoundTokenRelay: Bool { - [ - "CMUX_IROH_CUSTOM_RELAY_BOUND_URL", - "CMUX_IROH_CUSTOM_RELAY_FIRST_SECRET_KEY_HEX", - "CMUX_IROH_CUSTOM_RELAY_FIRST_TOKEN", - "CMUX_IROH_CUSTOM_RELAY_SECOND_SECRET_KEY_HEX", - "CMUX_IROH_CUSTOM_RELAY_SECOND_TOKEN", - ].allSatisfy { environment[$0]?.isEmpty == false } - } - - static var hasBrokerCredentials: Bool { - [ - "CMUX_IROH_CUSTOM_RELAY_BROKER_URL", - "CMUX_IROH_CUSTOM_RELAY_ACCESS_TOKEN", - "CMUX_IROH_CUSTOM_RELAY_REFRESH_TOKEN", - ].allSatisfy { environment[$0]?.isEmpty == false } - } - - static var timeout: TimeInterval { - environment["CMUX_IROH_CUSTOM_RELAY_TIMEOUT"] - .flatMap(TimeInterval.init) ?? 10 - } - - static func required(_ name: String) throws -> String { - guard let value = environment[name], !value.isEmpty else { - throw EnvironmentError.missing(name) - } - return value - } - - static func requiredSecretKey(_ name: String) throws -> CmxIrohSecretKey { - let value = try required(name) - guard value.utf8.count == 64 else { - throw EnvironmentError.invalid(name) - } - var bytes = Data(capacity: 32) - var index = value.startIndex - while index < value.endIndex { - let next = value.index(index, offsetBy: 2) - guard let byte = UInt8(value[index ..< next], radix: 16) else { - throw EnvironmentError.invalid(name) - } - bytes.append(byte) - index = next - } - return try CmxIrohSecretKey(bytes: bytes) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift deleted file mode 100644 index b3ff5daa..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift +++ /dev/null @@ -1,512 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing -@testable import CmuxIrohTransport - -/// Opt-in live checks for real custom relay address selection. CI skips this -/// suite unless `CMUX_IROH_CUSTOM_RELAY_LIVE=1` is explicitly present. -@Suite( - .serialized, - .enabled(if: CmxIrohCustomRelayLiveEnvironment.isEnabled) -) -struct CmxIrohCustomRelayLiveTests { - private enum LiveTestError: Error { - case connectionTimedOut - case endpointClosed - case relayAddressTimedOut - case relayPathTimedOut - case streamRoundTripTimedOut - } - - private struct ConnectionPair: Sendable { - let outgoing: any CmxIrohConnection - let incoming: any CmxIrohConnection - } - - @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasNoTokenRelay)) - func unauthenticatedRelayUsesExactConfiguredURL() async throws { - let relayURL = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_NO_TOKEN_URL" - ) - let profile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: relayURL)] - ) - ) - - let result = await CmxIrohCustomRelayProbe().probe( - profile: profile, - timeout: CmxIrohCustomRelayLiveEnvironment.timeout - ) - - #expect(result == .reachable(relayURL: relayURL)) - } - - @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasStaticTokenRelay)) - func staticTokenProfileAdvertisesExactConfiguredURL() async throws { - // Relay advertisement proves exact FFI map selection, not provider - // authentication. The product does not present this as a token test. - let relayURL = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_STATIC_URL" - ) - let token = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_STATIC_TOKEN" - ) - let profile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [ - CmxIrohCustomRelay( - url: relayURL, - authenticationToken: token - ), - ] - ) - ) - - let result = await CmxIrohCustomRelayProbe().probe( - profile: profile, - timeout: CmxIrohCustomRelayLiveEnvironment.timeout - ) - - #expect(result == .reachable(relayURL: relayURL)) - } - - @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasStaticTokenRelay)) - func staticTokenRelayCarriesBidirectionalRoundTrip() async throws { - let relayURL = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_STATIC_URL" - ) - let token = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_STATIC_TOKEN" - ) - try await assertBidirectionalRoundTrip( - relayURL: relayURL, - firstAuthenticationToken: token, - secondAuthenticationToken: token - ) - } - - @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasNoTokenRelay)) - func unauthenticatedRelayCarriesBidirectionalRoundTrip() async throws { - let relayURL = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_NO_TOKEN_URL" - ) - try await assertBidirectionalRoundTrip( - relayURL: relayURL, - firstAuthenticationToken: nil, - secondAuthenticationToken: nil - ) - } - - @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasEndpointBoundTokenRelay)) - func endpointBoundTokensCarryBidirectionalRoundTrip() async throws { - try await assertBidirectionalRoundTrip( - relayURL: try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_BOUND_URL" - ), - firstAuthenticationToken: try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_FIRST_TOKEN" - ), - secondAuthenticationToken: try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_SECOND_TOKEN" - ), - firstSecretKey: try CmxIrohCustomRelayLiveEnvironment.requiredSecretKey( - "CMUX_IROH_CUSTOM_RELAY_FIRST_SECRET_KEY_HEX" - ), - secondSecretKey: try CmxIrohCustomRelayLiveEnvironment.requiredSecretKey( - "CMUX_IROH_CUSTOM_RELAY_SECOND_SECRET_KEY_HEX" - ) - ) - } - - @Test(.enabled(if: CmxIrohCustomRelayLiveEnvironment.hasBrokerCredentials)) - func brokerBoundTokensCarryBidirectionalRoundTrip() async throws { - let baseURL = try #require(URL(string: try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_BROKER_URL" - ))) - let accessToken = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_ACCESS_TOKEN" - ) - let refreshToken = try CmxIrohCustomRelayLiveEnvironment.required( - "CMUX_IROH_CUSTOM_RELAY_REFRESH_TOKEN" - ) - let broker = try CmxIrohTrustBrokerClient( - baseURL: baseURL, - tokenSource: CmxIrohBrokerTokenSource( - accessToken: { accessToken }, - refreshToken: { refreshToken } - ) - ) - let runTag = "relay-live-\(UUID().uuidString.lowercased())" - let firstSecretKey = try randomSecretKey() - let secondSecretKey = try randomSecretKey() - var bindingIDs: [String] = [] - var stage = "register first endpoint" - - do { - print("Iroh live relay: \(stage)") - let first = try await register( - secretKey: firstSecretKey, - tag: "\(runTag)-first", - broker: broker - ) - bindingIDs.append(first.bindingID) - stage = "register second endpoint" - print("Iroh live relay: \(stage)") - let second = try await register( - secretKey: secondSecretKey, - tag: "\(runTag)-second", - broker: broker - ) - bindingIDs.append(second.bindingID) - - stage = "mint first endpoint-bound token" - print("Iroh live relay: \(stage)") - let firstToken = try await broker.issueRelayToken( - bindingID: first.bindingID, - endpointID: first.endpointID - ) - stage = "mint second endpoint-bound token" - print("Iroh live relay: \(stage)") - let secondToken = try await broker.issueRelayToken( - bindingID: second.bindingID, - endpointID: second.endpointID - ) - let firstCredentials = Dictionary( - firstToken.credentials.map { ($0.relayURL, $0.token) }, - uniquingKeysWith: { _, latestToken in latestToken } - ) - let commonRelayURL = try #require( - secondToken.credentials.lazy - .map(\.relayURL) - .first(where: { firstCredentials[$0] != nil }) - ) - let firstAuthenticationToken = try #require(firstCredentials[commonRelayURL]) - let secondAuthenticationToken = try #require( - secondToken.credentials.first(where: { - $0.relayURL == commonRelayURL - })?.token - ) - - stage = "carry bidirectional relay-only stream" - print("Iroh live relay: \(stage)") - try await assertBidirectionalRoundTrip( - relayURL: commonRelayURL, - firstAuthenticationToken: firstAuthenticationToken, - secondAuthenticationToken: secondAuthenticationToken, - firstSecretKey: firstSecretKey, - secondSecretKey: secondSecretKey - ) - } catch { - Issue.record("Iroh live relay failed while attempting to \(stage): \(error)") - await revoke(bindingIDs: bindingIDs, broker: broker) - throw error - } - await revoke(bindingIDs: bindingIDs, broker: broker) - } - - private struct RegisteredEndpoint: Sendable { - let bindingID: String - let endpointID: CmxIrohPeerIdentity - } - - private func register( - secretKey: CmxIrohSecretKey, - tag: String, - broker: CmxIrohTrustBrokerClient - ) async throws -> RegisteredEndpoint { - let privateKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: secretKey.bytes - ) - let endpointID = privateKey.publicKey.rawRepresentation - .map { String(format: "%02x", $0) } - .joined() - let identity = try CmxIrohIdentityMaterial( - secretKey: secretKey, - generation: 1 - ) - let signer = try CmxIrohRegistrationSigner( - identity: identity, - endpointID: endpointID - ) - let payload = try CmxIrohRegistrationPayload( - deviceID: UUID().uuidString.lowercased(), - appInstanceID: UUID().uuidString.lowercased(), - tag: tag, - platform: .ios, - endpointID: endpointID, - identityGeneration: identity.generation, - pairingEnabled: false, - capabilities: ["rpc", "terminal.streams"], - pathHints: [] - ) - let registration = try await broker.register( - prepared: signer.prepare(payload: payload), - signer: signer - ) - #expect(registration.binding.endpointID.endpointID == endpointID) - return RegisteredEndpoint( - bindingID: registration.binding.bindingID, - endpointID: registration.binding.endpointID - ) - } - - private func randomSecretKey() throws -> CmxIrohSecretKey { - try CmxIrohSecretKey(bytes: Data((0 ..< 32).map { _ in UInt8.random(in: .min ... .max) })) - } - - private func revoke( - bindingIDs: [String], - broker: CmxIrohTrustBrokerClient - ) async { - for bindingID in bindingIDs.reversed() { - do { - try await broker.revoke(bindingID: bindingID) - } catch { - Issue.record("Failed to revoke disposable live-test binding") - } - } - } - - private func assertBidirectionalRoundTrip( - relayURL: String, - firstAuthenticationToken: String?, - secondAuthenticationToken: String?, - firstSecretKey: CmxIrohSecretKey? = nil, - secondSecretKey: CmxIrohSecretKey? = nil - ) async throws { - let firstProfile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [ - CmxIrohCustomRelay( - url: relayURL, - authenticationToken: firstAuthenticationToken - ), - ] - ) - ) - let secondProfile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [ - CmxIrohCustomRelay( - url: relayURL, - authenticationToken: secondAuthenticationToken - ), - ] - ) - ) - let factory = CmxIrohLibEndpointFactory( - transportVerificationMode: .relayOnly - ) - let alpn = Data("cmux/custom-relay-live/1".utf8) - let first = try await factory.bind( - configuration: CmxIrohEndpointConfiguration( - secretKey: try firstSecretKey ?? CmxIrohSecretKey( - bytes: Data(repeating: 1, count: 32) - ), - alpns: [alpn], - relayProfile: firstProfile - ) - ) - print("Iroh live relay: first endpoint bound") - let second = try await factory.bind( - configuration: CmxIrohEndpointConfiguration( - secretKey: try secondSecretKey ?? CmxIrohSecretKey( - bytes: Data(repeating: 2, count: 32) - ), - alpns: [alpn], - relayProfile: secondProfile - ) - ) - print("Iroh live relay: second endpoint bound") - - do { - let firstAddress = try await relayAddress( - for: first, - relayURL: relayURL - ) - print("Iroh live relay: first relay address advertised") - let secondAddress = try await relayAddress( - for: second, - relayURL: relayURL - ) - print("Iroh live relay: second relay address advertised") - #expect(firstAddress.pathHints.map(\.value) == [relayURL]) - #expect(secondAddress.pathHints.map(\.value) == [relayURL]) - - let connections = try await connectPair( - first: first, - second: second, - secondAddress: secondAddress, - alpn: alpn - ) - print("Iroh live relay: endpoint connection established") - let outgoingConnection = connections.outgoing - let incomingConnection = connections.incoming - - try await carryBoundedStreamRoundTrip( - outgoingConnection: outgoingConnection, - incomingConnection: incomingConnection - ) - - #expect( - try await relayPath( - for: outgoingConnection, - relayURL: relayURL - ) == .relay(url: relayURL) - ) - print("Iroh live relay: outgoing path verified as relay") - #expect( - try await relayPath( - for: incomingConnection, - relayURL: relayURL - ) == .relay(url: relayURL) - ) - print("Iroh live relay: incoming path verified as relay") - - await outgoingConnection.close(errorCode: 0, reason: "live_test_complete") - await incomingConnection.close(errorCode: 0, reason: "live_test_complete") - } catch { - await first.close() - await second.close() - throw error - } - await first.close() - await second.close() - } - - private func carryBoundedStreamRoundTrip( - outgoingConnection: any CmxIrohConnection, - incomingConnection: any CmxIrohConnection - ) async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await outgoingConnection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 1, - maximumUnidirectionalStreamCount: 0 - ) - print("Iroh live relay: outgoing stream limits configured") - try await incomingConnection.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 1, - maximumUnidirectionalStreamCount: 0 - ) - print("Iroh live relay: incoming stream limits configured") - - async let acceptedStream = incomingConnection.acceptBidirectionalStream() - print("Iroh live relay: waiting for incoming stream") - let outgoingStream = try await outgoingConnection.openBidirectionalStream() - print("Iroh live relay: outgoing stream created") - let request = Data("custom-relay-request".utf8) - try await outgoingStream.sendStream.send(request) - try await outgoingStream.sendStream.finish() - let incomingStream = try await acceptedStream - print("Iroh live relay: bidirectional stream opened") - #expect(try await self.receiveAll(from: incomingStream.receiveStream) == request) - print("Iroh live relay: request received") - - let response = Data("custom-relay-response".utf8) - try await incomingStream.sendStream.send(response) - try await incomingStream.sendStream.finish() - #expect(try await self.receiveAll(from: outgoingStream.receiveStream) == response) - print("Iroh live relay: response received") - } - group.addTask { - try await ContinuousClock().sleep( - for: .seconds(CmxIrohCustomRelayLiveEnvironment.timeout) - ) - await outgoingConnection.close(errorCode: 1, reason: "live_test_timeout") - await incomingConnection.close(errorCode: 1, reason: "live_test_timeout") - throw LiveTestError.streamRoundTripTimedOut - } - defer { group.cancelAll() } - _ = try await group.next() - } - } - - private func connectPair( - first: any CmxIrohEndpoint, - second: any CmxIrohEndpoint, - secondAddress: CmxIrohEndpointAddress, - alpn: Data - ) async throws -> ConnectionPair { - try await withThrowingTaskGroup(of: ConnectionPair.self) { group in - group.addTask { - async let acceptedConnection = second.accept() - let outgoingConnection = try await first.connect( - to: secondAddress, - alpn: alpn - ) - let incomingConnection = try #require(await acceptedConnection) - return ConnectionPair( - outgoing: outgoingConnection, - incoming: incomingConnection - ) - } - group.addTask { - try await ContinuousClock().sleep( - for: .seconds(CmxIrohCustomRelayLiveEnvironment.timeout) - ) - // The FFI connect/accept futures do not currently unwind from - // Swift task cancellation alone. Closing both disposable live - // endpoints makes this external-network gate deterministically bounded. - await first.close() - await second.close() - throw LiveTestError.connectionTimedOut - } - defer { group.cancelAll() } - guard let pair = try await group.next() else { - throw LiveTestError.connectionTimedOut - } - return pair - } - } - - private func relayAddress( - for endpoint: any CmxIrohEndpoint, - relayURL: String - ) async throws -> CmxIrohEndpointAddress { - let deadline = Date().addingTimeInterval(CmxIrohCustomRelayLiveEnvironment.timeout) - while Date() < deadline { - let address = await endpoint.address() - if address.pathHints.contains(where: { - $0.kind == .relayURL && $0.value == relayURL - }) { - return address - } - if !(await endpoint.isHealthy()) { - throw LiveTestError.endpointClosed - } - try await Task.sleep(for: .milliseconds(100)) - } - throw LiveTestError.relayAddressTimedOut - } - - private func relayPath( - for connection: any CmxIrohConnection, - relayURL: String - ) async throws -> CmxIrohObservedConnectionPath { - let connection = try #require( - connection as? any CmxIrohConnectionPathInspecting - ) - let deadline = Date().addingTimeInterval(CmxIrohCustomRelayLiveEnvironment.timeout) - while Date() < deadline { - let path = await connection.observedSelectedPath() - if path == .relay(url: relayURL) { - return path - } - try await Task.sleep(for: .milliseconds(100)) - } - throw LiveTestError.relayPathTimedOut - } - - private func receiveAll( - from stream: any CmxIrohReceiveStream - ) async throws -> Data { - var result = Data() - while let chunk = try await stream.receive(maximumByteCount: 4_096) { - result.append(chunk) - } - return result - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift deleted file mode 100644 index 005f34e9..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayProbeTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohCustomRelayProbeTests { - @Test - func isolatedProbeReportsOnlyAnAllowedAdvertisedRelayAndCloses() async throws { - let fixture = try ClientRuntimeTestFixture() - let relayURL = "https://private.example.net:8443/" - let now = Date() - let hint = try CmxIrohPathHint( - kind: .relayURL, - value: relayURL, - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(60) - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID, pathHints: [hint]) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let profile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: relayURL)] - ) - ) - - let result = await CmxIrohCustomRelayProbe(factory: factory).probe( - profile: profile, - timeout: 1 - ) - - #expect(result == .reachable(relayURL: relayURL)) - #expect(await endpoint.observedCloseCallCount() == 1) - let configuration = try #require(await factory.observedConfigurations().first) - #expect(configuration.relayProfile == profile) - #expect(configuration.secretKey != fixture.identity.secretKey) - } - - @Test - func managedProfileIsRejectedBeforeBinding() async throws { - let fixture = try ClientRuntimeTestFixture() - let factory = TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ) - let profile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: fixture.configuration.managedRelayURLs, - relays: [] - ) - - let result = await CmxIrohCustomRelayProbe(factory: factory).probe( - profile: profile - ) - - #expect(result == .invalidProfile) - #expect(await factory.observedConfigurations().isEmpty) - } - - @Test - func timeoutCancelsObservationAndClosesEndpoint() async throws { - let fixture = try ClientRuntimeTestFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let profile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] - ) - ) - - let result = await CmxIrohCustomRelayProbe( - factory: factory, - clock: ImmediateProbeClock() - ).probe(profile: profile, timeout: 1) - - #expect(result == .timedOut) - #expect(await endpoint.observedCloseCallCount() == 1) - } -} - -private struct ImmediateProbeClock: CmxIrohRelayClock { - func now() -> Date { - Date(timeIntervalSince1970: 1_000) - } - - func sleep(until _: Date) async throws {} -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift deleted file mode 100644 index 32ade7c4..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayRuntimeTests.swift +++ /dev/null @@ -1,156 +0,0 @@ -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohCustomRelayRuntimeTests { - @Test - func clientOverrideSkipsManagedTokenIssuance() async throws { - let fixture = try ClientRuntimeTestFixture() - let custom = try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] - ) - let profile = CmxIrohEndpointRelayProfile(customProfile: custom) - let configuration = CmxIrohClientRuntimeConfiguration( - accountID: fixture.configuration.accountID, - deviceID: fixture.configuration.deviceID, - appInstanceID: fixture.configuration.appInstanceID, - tag: fixture.configuration.tag, - displayName: fixture.configuration.displayName, - identity: fixture.identity, - capabilities: fixture.configuration.capabilities, - managedRelayURLs: fixture.configuration.managedRelayURLs, - endpointRelayProfile: profile - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ) - let runtime = try CmxIrohClientRuntime( - factory: factory, - broker: broker, - configuration: configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - - try await runtime.start() - - #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(await factory.observedConfigurations().first?.relayProfile == profile) - await runtime.stop() - } - - @Test - func hostOverrideSkipsManagedTokenIssuance() async throws { - let fixture = try HostRuntimeFixture() - let custom = try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://private.example.net:8443/")] - ) - let profile = CmxIrohEndpointRelayProfile(customProfile: custom) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ) - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration(endpointRelayProfile: profile), - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - - #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) - #expect(await factory.observedConfigurations().first?.relayProfile == profile) - await runtime.stop() - } - - @Test - func clientReplacesCustomProfileWithoutClosingEndpoint() async throws { - let fixture = try ClientRuntimeTestFixture() - let initial = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://first.example.net/")] - ) - ) - let replacement = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://second.example.net:8443/")] - ) - ) - let configuration = CmxIrohClientRuntimeConfiguration( - accountID: fixture.configuration.accountID, - deviceID: fixture.configuration.deviceID, - appInstanceID: fixture.configuration.appInstanceID, - tag: fixture.configuration.tag, - displayName: fixture.configuration.displayName, - identity: fixture.identity, - capabilities: fixture.configuration.capabilities, - managedRelayURLs: fixture.configuration.managedRelayURLs, - endpointRelayProfile: initial - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let runtime = try CmxIrohClientRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohClientBroker( - binding: fixture.binding, - discovery: fixture.discovery, - relay: fixture.relayResponse() - ), - configuration: configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { fixture.now } - ) - try await runtime.start() - - try await runtime.replaceRelayProfile(replacement) - - #expect(await endpoint.observedRelayProfileUpdates().last == replacement) - #expect(await endpoint.observedCloseCallCount() == 0) - #expect(await runtime.snapshot().endpointID == fixture.endpointID) - await runtime.stop() - } - - @Test - func hostReplacesCustomProfileWithoutClosingEndpoint() async throws { - let fixture = try HostRuntimeFixture() - let initial = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://first.example.net/")] - ) - ) - let replacement = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://second.example.net:8443/")] - ) - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ), - configuration: fixture.configuration(endpointRelayProfile: initial), - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() } - ) - try await runtime.start() - - try await runtime.replaceRelayProfile(replacement) - - #expect(await endpoint.observedRelayProfileUpdates().last == replacement) - #expect(await endpoint.observedCloseCallCount() == 0) - #expect(await runtime.snapshot().endpointID == fixture.endpointID) - await runtime.stop() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDevelopmentFileStorageTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDevelopmentFileStorageTests.swift deleted file mode 100644 index 5bddbea4..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDevelopmentFileStorageTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite(.serialized) -struct CmxIrohDevelopmentFileStorageTests { - @Test func identityRoundTripsWithPrivateFilesystemPermissions() throws { - let fixture = try Fixture() - defer { fixture.remove() } - let store = CmxIrohDevelopmentFileIdentityStore( - directory: fixture.directory - ) - - try store.write(Data([1, 2, 3]), account: "identity-scope") - - #expect(try store.read(account: "identity-scope") == Data([1, 2, 3])) - #expect(try fixture.permissions(at: fixture.directory) == 0o700) - #expect(try fixture.permissions( - at: fixture.directory.appendingPathComponent( - "identity-scope.cmux-iroh" - ) - ) == 0o600) - } - - @Test func credentialStoreDeletesOnlyItsRecords() async throws { - let fixture = try Fixture() - defer { fixture.remove() } - try FileManager.default.createDirectory( - at: fixture.directory, - withIntermediateDirectories: true - ) - let unrelated = fixture.directory.appendingPathComponent("keep.txt") - try Data("keep".utf8).write(to: unrelated) - let store = CmxIrohDevelopmentFileCredentialStore( - directory: fixture.directory - ) - - try await store.write( - Data("one".utf8), - account: "active-host-policy", - accessibility: .afterFirstUnlockThisDeviceOnly - ) - try await store.write( - Data("two".utf8), - account: "active-client-policies", - accessibility: .afterFirstUnlockThisDeviceOnly - ) - try await store.deleteAll() - - #expect(try await store.read(account: "active-host-policy") == nil) - #expect(try await store.read(account: "active-client-policies") == nil) - #expect(FileManager.default.fileExists(atPath: unrelated.path)) - } - - @Test func traversalScopeIsRejected() throws { - let fixture = try Fixture() - defer { fixture.remove() } - let store = CmxIrohDevelopmentFileIdentityStore( - directory: fixture.directory - ) - - #expect(throws: CmxIrohDevelopmentFileStoreError.invalidAccount) { - try store.write(Data([1]), account: "../outside") - } - } - - private struct Fixture { - let root: URL - let directory: URL - - init() throws { - root = FileManager.default.temporaryDirectory.appendingPathComponent( - "cmux-iroh-development-store-\(UUID().uuidString)", - isDirectory: true - ) - directory = root.appendingPathComponent("store", isDirectory: true) - } - - func permissions(at url: URL) throws -> Int { - let attributes = try FileManager.default.attributesOfItem( - atPath: url.path - ) - return (attributes[.posixPermissions] as? NSNumber)?.intValue ?? -1 - } - - func remove() { - try? FileManager.default.removeItem(at: root) - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDiagnosticFailureTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDiagnosticFailureTests.swift deleted file mode 100644 index 4549cf36..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDiagnosticFailureTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import CMUXMobileCore -import Testing - -@testable import CmuxIrohTransport - -@Suite struct CmxIrohDiagnosticFailureTests { - @Test func mapsRepresentativeFailuresWithoutInspectingAssociatedText() { - #expect( - DiagnosticFailureKind.classify( - CmxIrohTrustBrokerClientError.rejected(statusCode: 403, code: "private-code") - ) == .authorizationFailed - ) - #expect( - DiagnosticFailureKind.classify( - CmxIrohLibError.unmanagedRelayURL("https://private-relay.example") - ) == .policyUnavailable - ) - #expect( - DiagnosticFailureKind.classify(CmxIrohGrantVerifierError.accountMismatch) - == .accountMismatch - ) - #expect( - DiagnosticFailureKind.classify(CmxIrohClientSessionError.admissionDenied(code: 9)) - == .admissionDenied - ) - #expect( - DiagnosticFailureKind.classify(CmxIrohKeychainIdentityStoreError(status: -50)) - == .credentialUnavailable - ) - #expect( - DiagnosticFailureKind.classify(CmxIrohClientRuntimeError.superseded) - == .superseded - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectPortsTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectPortsTests.swift deleted file mode 100644 index ce44669a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectPortsTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohDirectPortsTests { - @Test - func derivesIndependentAddressFamilyPorts() throws { - let ports = try #require(CmxIrohDirectPorts(localDirectAddresses: [ - "0.0.0.0:50909", - "[::]:54750", - "100.82.214.112:50909", - "[fd7a:115c:a1e0::4b36:d670]:54750", - "198.51.100.20:60000", - ])) - - let expected = try CmxIrohDirectPorts(ipv4: 50_909, ipv6: 54_750) - #expect(ports == expected) - } - - @Test - func ambiguousFamilyIsOmittedRatherThanGuessed() throws { - let ports = try #require(CmxIrohDirectPorts(localDirectAddresses: [ - "192.168.1.10:50909", - "203.0.113.10:60000", - "[fd7a:115c:a1e0::4b36:d670]:54750", - ])) - - let expected = try CmxIrohDirectPorts(ipv6: 54_750) - #expect(ports == expected) - } - - @Test - func decodedPortsRequireAtLeastOneNonzeroValue() throws { - let decoder = JSONDecoder() - #expect(throws: (any Error).self) { - try decoder.decode(CmxIrohDirectPorts.self, from: Data("{}".utf8)) - } - #expect(throws: (any Error).self) { - try decoder.decode( - CmxIrohDirectPorts.self, - from: Data(#"{"ipv4":0}"#.utf8) - ) - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift deleted file mode 100644 index 6e7cb8d2..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohDirectTransportGateTests.swift +++ /dev/null @@ -1,166 +0,0 @@ -import CMUXMobileCore -import Foundation -import IrohLib -import Testing -@testable import CmuxIrohTransport - -/// Deterministic relay-disabled transport proof used by the iOS Simulator -/// release gate. Both peers are real Iroh endpoints. No cmux TCP transport is -/// constructed, so a raw loopback connection cannot satisfy this suite. -@Suite(.serialized) -struct CmxIrohDirectTransportGateTests { - private enum GateError: Error { - case connectionTimedOut - case selectedPathTimedOut - } - - private struct ConnectionPair: Sendable { - let outgoing: CmxIrohLibConnection - let incoming: CmxIrohLibConnection - } - - @Test - func relayDisabledEndpointsCarryAuthenticatedBidirectionalRoundTrip() async throws { - let alpn = Data("cmux/direct-transport-gate/1".utf8) - let first = try await endpoint(secretByte: 41, alpn: alpn) - let second = try await endpoint(secretByte: 42, alpn: alpn) - - do { - let secondAddress = second.addr() - #expect(secondAddress.relayUrl() == nil) - #expect(!secondAddress.directAddresses().isEmpty) - - let pair = try await connectPair( - first: first, - second: second, - secondAddress: secondAddress, - alpn: alpn - ) - let firstIdentity = try CmxIrohLibIdentity.peerIdentity(first.id()) - let secondIdentity = try CmxIrohLibIdentity.peerIdentity(second.id()) - #expect(await pair.outgoing.remoteIdentity() == secondIdentity) - #expect(await pair.incoming.remoteIdentity() == firstIdentity) - - try await pair.outgoing.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 1, - maximumUnidirectionalStreamCount: 0 - ) - try await pair.incoming.setIncomingStreamLimits( - maximumBidirectionalStreamCount: 1, - maximumUnidirectionalStreamCount: 0 - ) - try await pair.outgoing.authorizeNatTraversal() - try await pair.incoming.authorizeNatTraversal() - - async let acceptedStream = pair.incoming.acceptBidirectionalStream() - let outgoingStream = try await pair.outgoing.openBidirectionalStream() - let request = Data("direct-gate-request".utf8) - try await outgoingStream.sendStream.send(request) - try await outgoingStream.sendStream.finish() - let incomingStream = try await acceptedStream - #expect(try await receiveAll(from: incomingStream.receiveStream) == request) - - let response = Data("direct-gate-response".utf8) - try await incomingStream.sendStream.send(response) - try await incomingStream.sendStream.finish() - #expect(try await receiveAll(from: outgoingStream.receiveStream) == response) - - #expect(try await directPath(for: pair.outgoing)) - #expect(try await directPath(for: pair.incoming)) - - await pair.outgoing.close(errorCode: 0, reason: "direct_gate_complete") - await pair.incoming.close(errorCode: 0, reason: "direct_gate_complete") - } catch { - try? await first.close() - try? await second.close() - throw error - } - try await first.close() - try await second.close() - } - - private func endpoint( - secretByte: UInt8, - alpn: Data - ) async throws -> Endpoint { - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: secretByte, count: 32)), - alpns: [alpn], - managedRelayURLs: [], - relays: [] - ) - let options = CmxIrohLibEndpointFactory.endpointOptions( - configuration: configuration, - socketAddress: "127.0.0.1:0", - relayMap: RelayMap.empty(), - transportVerificationMode: .directOnly - ) - #expect(options.relayMode?.description == "disabled") - #expect(options.bindAddr == "127.0.0.1:0") - #expect(options.initialMaxConcurrentBiStreams == 0) - #expect(options.initialMaxConcurrentUniStreams == 0) - return try await Endpoint.bind(options: options) - } - - private func connectPair( - first: Endpoint, - second: Endpoint, - secondAddress: EndpointAddr, - alpn: Data - ) async throws -> ConnectionPair { - try await withThrowingTaskGroup(of: ConnectionPair.self) { group in - group.addTask { - async let incoming = self.acceptConnection(from: second, alpn: alpn) - let outgoing = try CmxIrohLibConnection( - driver: try await first.connect(addr: secondAddress, alpn: alpn) - ) - return ConnectionPair(outgoing: outgoing, incoming: try await incoming) - } - group.addTask { - try await ContinuousClock().sleep(for: .seconds(20)) - try? await first.close() - try? await second.close() - throw GateError.connectionTimedOut - } - defer { group.cancelAll() } - return try #require(await group.next()) - } - } - - private func acceptConnection( - from endpoint: Endpoint, - alpn: Data - ) async throws -> CmxIrohLibConnection { - let incoming = try #require(await endpoint.acceptNext()) - let accepting = try await incoming.accept() - #expect(try await accepting.alpn() == alpn) - return try CmxIrohLibConnection(driver: try await accepting.connect()) - } - - private func directPath( - for connection: CmxIrohLibConnection - ) async throws -> Bool { - let deadline = ContinuousClock().now.advanced(by: .seconds(10)) - while ContinuousClock().now < deadline { - switch await connection.observedSelectedPath() { - case .direct, .privateNetwork: - return true - case .relay: - return false - case .unavailable: - try await ContinuousClock().sleep(for: .milliseconds(50)) - } - } - throw GateError.selectedPathTimedOut - } - - private func receiveAll( - from stream: any CmxIrohReceiveStream - ) async throws -> Data { - var result = Data() - while let chunk = try await stream.receive(maximumByteCount: 4_096) { - result.append(chunk) - } - return result - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift deleted file mode 100644 index e59448b8..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift +++ /dev/null @@ -1,283 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohEndpointServerTests { - @Test - func fullServerReservesOnePendingReconnectForAnActiveIdentity() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "8", count: 64) - ) - let activeIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "9", count: 64) - ) - let newIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let started = EndpointServerRecorder() - let admitted = EndpointServerRecorder() - let replacementAuthorization = EndpointServerHandlerBlocker() - let connectionLifetime = EndpointServerHandlerBlocker() - let server = CmxIrohEndpointServer( - supervisor: supervisor, - maximumConnections: 1, - maximumConnectionsPerIdentity: 1 - ) { connection, generation, markAdmitted in - let identity = await connection.remoteIdentity() - await started.record(identity: identity, generation: generation) - if await started.recordedCount() == 2 { - await replacementAuthorization.wait() - } - #expect(await markAdmitted()) - await admitted.record(identity: identity, generation: generation) - await connectionLifetime.wait() - } - let active = TestIrohConnection( - remoteIdentity: activeIdentity, - bidirectionalStreams: [] - ) - let replacement = TestIrohConnection( - remoteIdentity: activeIdentity, - bidirectionalStreams: [] - ) - let newcomer = TestIrohConnection( - remoteIdentity: newIdentity, - bidirectionalStreams: [] - ) - var activeCloses = await active.closeEvents().makeAsyncIterator() - var newcomerCloses = await newcomer.closeEvents().makeAsyncIterator() - - await server.start() - await endpoint.enqueue(active) - #expect(await started.next().identity == activeIdentity) - #expect(await admitted.next().identity == activeIdentity) - - await endpoint.enqueue(replacement) - for _ in 0 ..< 100 { - let startedCount = await started.recordedCount() - let replacementCloseCount = await replacement.observedCloseCallCount() - guard startedCount < 2, replacementCloseCount == 0 else { break } - await Task.yield() - } - let replacementStarted = await started.recordedCount() == 2 - #expect(replacementStarted) - guard replacementStarted else { - await connectionLifetime.releaseAll() - await server.stop() - await supervisor.deactivate() - return - } - #expect(await active.observedCloseCallCount() == 0) - - await endpoint.enqueue(newcomer) - await newcomer.waitUntilClosed() - let newcomerClose = try #require(await newcomerCloses.next()) - #expect(newcomerClose.reason == "connection_capacity") - - await replacementAuthorization.releaseAll() - #expect(await admitted.next().identity == activeIdentity) - let activeClose = try #require(await activeCloses.next()) - #expect(activeClose.reason == "superseded_connection") - #expect(await replacement.observedCloseCallCount() == 0) - - await connectionLifetime.releaseAll() - await server.stop() - await supervisor.deactivate() - } - - @Test - func oneEndpointIdentityCannotConsumeEveryPendingAdmissionSlot() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "e", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "f", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 3, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let blocker = EndpointServerHandlerBlocker() - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer( - supervisor: supervisor, - maximumPendingAdmissions: 3 - ) { connection, generation, _ in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - if await recorder.recordedCount() == 1 { - await blocker.wait() - } else { - await connection.close(errorCode: 0, reason: "handler_accepted") - } - } - let first = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - let duplicate = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - var duplicateCloses = await duplicate.closeEvents().makeAsyncIterator() - - await server.start() - await endpoint.enqueue(first) - #expect(await recorder.next().identity == remoteIdentity) - await endpoint.enqueue(duplicate) - - let close = try #require(await duplicateCloses.next()) - #expect(close.reason == "admission_identity_capacity") - - await blocker.releaseAll() - await server.stop() - await supervisor.deactivate() - } - - @Test - func sameEndpointReconnectsDoNotConsumeEveryLiveConnectionSlot() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "1", count: 64) - ) - let firstRemoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "2", count: 64) - ) - let secondRemoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "3", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 5, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let blocker = EndpointServerHandlerBlocker() - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer(supervisor: supervisor) { - connection, - generation, - markAdmitted in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - #expect(await markAdmitted()) - await blocker.wait() - } - - await server.start() - var reconnects: [TestIrohConnection] = [] - for _ in 0 ..< 3 { - let reconnect = TestIrohConnection( - remoteIdentity: firstRemoteIdentity, - bidirectionalStreams: [] - ) - reconnects.append(reconnect) - await endpoint.enqueue(reconnect) - #expect(await recorder.next().identity == firstRemoteIdentity) - if reconnects.count > 1 { - await reconnects[reconnects.count - 2].waitUntilClosed() - } - } - #expect(await reconnects[0].observedCloseCallCount() == 1) - #expect(await reconnects[1].observedCloseCallCount() == 1) - #expect(await reconnects[2].observedCloseCallCount() == 0) - #expect(await recorder.recordedCount() == 3) - - await endpoint.enqueue( - TestIrohConnection( - remoteIdentity: secondRemoteIdentity, - bidirectionalStreams: [] - ) - ) - #expect(await recorder.next().identity == secondRemoteIdentity) - - await blocker.releaseAll() - await server.stop() - await supervisor.deactivate() - } - - @Test - func failedReplacementAdmissionDoesNotCloseTheActiveConnection() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "4", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "5", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let blocker = EndpointServerHandlerBlocker() - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer(supervisor: supervisor) { - connection, - generation, - markAdmitted in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - if await recorder.recordedCount() == 1 { - #expect(await markAdmitted()) - await blocker.wait() - } - } - let active = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - let rejectedReplacement = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - - await server.start() - await endpoint.enqueue(active) - #expect(await recorder.next().identity == remoteIdentity) - await endpoint.enqueue(rejectedReplacement) - #expect(await recorder.next().identity == remoteIdentity) - for _ in 0 ..< 20 { await Task.yield() } - - #expect(await active.observedCloseCallCount() == 0) - #expect(await rejectedReplacement.observedCloseCallCount() == 1) - - await blocker.releaseAll() - await server.stop() - await supervisor.deactivate() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift deleted file mode 100644 index bef2b094..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests.swift +++ /dev/null @@ -1,466 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohEndpointServerTests { - @Test - func activeGenerationAcceptsThroughTheBoundedServerLoop() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 1, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - let snapshot = try await supervisor.activate() - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - await endpoint.enqueue(connection) - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer(supervisor: supervisor) { connection, generation, _ in - let identity = await connection.remoteIdentity() - await recorder.record( - identity: identity, - generation: generation - ) - await connection.close(errorCode: 0, reason: "test_complete") - } - - await server.start() - let observed = await recorder.next() - - #expect(observed.identity == remoteIdentity) - #expect(observed.generation == snapshot.runtimeGeneration) - #expect( - await server.isCurrent(runtimeGeneration: snapshot.runtimeGeneration) - ) - await server.stop() - await supervisor.deactivate() - } - - @Test - func oneFailedAcceptDoesNotKillTheActiveGeneration() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "1", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "2", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 4, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - let snapshot = try await supervisor.activate() - let clock = EndpointServerManualClock() - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer( - supervisor: supervisor, - clock: clock - ) { connection, generation, _ in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - } - - await server.start() - await endpoint.enqueueAcceptFailure() - await clock.waitUntilSleeping() - await clock.fire() - await endpoint.enqueue( - TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - ) - - let observed = await recorder.next() - #expect(observed.identity == remoteIdentity) - #expect(observed.generation == snapshot.runtimeGeneration) - #expect(await server.isCurrent(runtimeGeneration: snapshot.runtimeGeneration)) - await server.stop() - await supervisor.deactivate() - } - - @Test - func admissionTimeoutClosesTheConnectionAndReleasesCapacity() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "c", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "d", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 2, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let clock = EndpointServerManualClock() - let blocker = EndpointServerHandlerBlocker() - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer( - supervisor: supervisor, - maximumPendingAdmissions: 1, - admissionTimeout: 15, - clock: clock - ) { connection, generation, _ in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - await blocker.wait() - } - let first = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - var firstCloses = await first.closeEvents().makeAsyncIterator() - - await server.start() - await endpoint.enqueue(first) - _ = await recorder.next() - await clock.waitUntilSleeping() - await clock.fire() - - let close = try #require(await firstCloses.next()) - #expect(close.reason == "admission_timeout") - - let second = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - await endpoint.enqueue(second) - let admittedAfterTimeout = await recorder.next() - #expect(admittedAfterTimeout.identity == remoteIdentity) - - await blocker.releaseAll() - await server.stop() - await supervisor.deactivate() - } - - @Test - func admittedHandlerOutlivesPendingAdmissionDeadline() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "8", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "9", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 8, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let clock = EndpointServerManualClock() - let admissionGate = EndpointServerHandlerBlocker() - let blocker = EndpointServerHandlerBlocker() - let recorder = EndpointServerRecorder() - let admitted = EndpointServerRecorder() - let server = CmxIrohEndpointServer( - supervisor: supervisor, - admissionTimeout: 15, - clock: clock - ) { connection, generation, markAdmitted in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - await admissionGate.wait() - #expect(await markAdmitted()) - await admitted.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - await blocker.wait() - } - let connection = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - var closes = await connection.closeEvents().makeAsyncIterator() - - await server.start() - await endpoint.enqueue(connection) - #expect(await recorder.next().identity == remoteIdentity) - await clock.waitUntilSleeping() - await admissionGate.releaseAll() - #expect(await admitted.next().identity == remoteIdentity) - await clock.fire() - - #expect(await connection.observedCloseCallCount() == 0) - await server.stop() - let close = try #require(await closes.next()) - #expect(close.reason == "server_stopped") - - await blocker.releaseAll() - await supervisor.deactivate() - } - - @Test - func newlyAdmittedConnectionSupersedesOlderConnectionFromSameEndpointIdentity() async throws { - let localIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "a", count: 64) - ) - let remoteIdentity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ) - let endpoint = TestAcceptingIrohEndpoint(identity: localIdentity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 9, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - ) - _ = try await supervisor.activate() - let blocker = EndpointServerHandlerBlocker() - let recorder = EndpointServerRecorder() - let server = CmxIrohEndpointServer(supervisor: supervisor) { - connection, - generation, - markAdmitted in - await recorder.record( - identity: await connection.remoteIdentity(), - generation: generation - ) - #expect(await markAdmitted()) - await blocker.wait() - } - let first = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - let replacement = TestIrohConnection( - remoteIdentity: remoteIdentity, - bidirectionalStreams: [] - ) - var firstCloses = await first.closeEvents().makeAsyncIterator() - - await server.start() - await endpoint.enqueue(first) - #expect(await recorder.next().identity == remoteIdentity) - await endpoint.enqueue(replacement) - #expect(await recorder.next().identity == remoteIdentity) - - for _ in 0 ..< 20 { await Task.yield() } - let firstCloseCount = await first.observedCloseCallCount() - #expect(firstCloseCount == 1) - if firstCloseCount == 1 { - let close = try #require(await firstCloses.next()) - #expect(close.reason == "superseded_connection") - } - #expect(await replacement.observedCloseCallCount() == 0) - - await blocker.releaseAll() - await server.stop() - await supervisor.deactivate() - } - -} - -actor EndpointServerHandlerBlocker { - private var waiters: [CheckedContinuation<Void, Never>] = [] - private var released = false - - func wait() async { - guard !released else { return } - await withCheckedContinuation { waiters.append($0) } - } - - func releaseAll() { - released = true - let pending = waiters - waiters.removeAll() - for waiter in pending { waiter.resume() } - } -} - -actor EndpointServerManualClock: CmxIrohRelayClock { - private var sleeper: CheckedContinuation<Void, Never>? - private var sleepWaiters: [CheckedContinuation<Void, Never>] = [] - - nonisolated func now() -> Date { - Date(timeIntervalSince1970: 1_800_000_000) - } - - func sleep(until _: Date) async throws { - let waiters = sleepWaiters - sleepWaiters.removeAll() - for waiter in waiters { waiter.resume() } - await withTaskCancellationHandler { - await withCheckedContinuation { sleeper = $0 } - } onCancel: { - Task { await self.cancelSleep() } - } - try Task.checkCancellation() - } - - func waitUntilSleeping() async { - if sleeper != nil { return } - await withCheckedContinuation { sleepWaiters.append($0) } - } - - func fire() { - sleeper?.resume() - sleeper = nil - } - - private func cancelSleep() { - sleeper?.resume() - sleeper = nil - } -} - -actor EndpointServerRecorder { - typealias Event = (identity: CmxIrohPeerIdentity, generation: UInt64) - private var events: [Event] = [] - private var waiters: [CheckedContinuation<Event, Never>] = [] - private var totalRecordedCount = 0 - - func record(identity: CmxIrohPeerIdentity, generation: UInt64) { - totalRecordedCount += 1 - let event = (identity, generation) - if waiters.isEmpty { - events.append(event) - } else { - waiters.removeFirst().resume(returning: event) - } - } - - func next() async -> Event { - if !events.isEmpty { return events.removeFirst() } - return await withCheckedContinuation { waiters.append($0) } - } - - func recordedCount() -> Int { - totalRecordedCount - } -} - -actor TestAcceptingIrohEndpoint: CmxIrohEndpoint { - private enum AcceptEvent: Sendable { - case connection(any CmxIrohConnection) - case failure - case closed - } - - private let peerIdentity: CmxIrohPeerIdentity - private var acceptEvents: [AcceptEvent] = [] - private var waiters: [ - UUID: CheckedContinuation<AcceptEvent, Never> - ] = [:] - private let health: AsyncStream<CmxIrohEndpointHealthEvent> - private let healthContinuation: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - private var closed = false - - init(identity: CmxIrohPeerIdentity) { - peerIdentity = identity - let stream = AsyncStream<CmxIrohEndpointHealthEvent>.makeStream() - health = stream.stream - healthContinuation = stream.continuation - } - - func identity() -> CmxIrohPeerIdentity { peerIdentity } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: peerIdentity, pathHints: []) - } - - func connect( - to _: CmxIrohEndpointAddress, - alpn _: Data - ) async throws -> any CmxIrohConnection { - throw TestIrohTransportError.unsupported - } - - func accept() async throws -> (any CmxIrohConnection)? { - try Task.checkCancellation() - if !acceptEvents.isEmpty { - return try Self.resolve(acceptEvents.removeFirst()) - } - guard !closed else { return nil } - let id = UUID() - let event = await withTaskCancellationHandler { - await withCheckedContinuation { waiters[id] = $0 } - } onCancel: { - Task { await self.cancelAccept(id) } - } - try Task.checkCancellation() - return try Self.resolve(event) - } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { health } - func isHealthy() -> Bool { true } - - func close() { - closed = true - let pending = waiters.values - waiters.removeAll() - for continuation in pending { continuation.resume(returning: .closed) } - healthContinuation.finish() - } - - func enqueue(_ connection: any CmxIrohConnection) { - enqueue(.connection(connection)) - } - - func enqueueAcceptFailure() { - enqueue(.failure) - } - - private func enqueue(_ event: AcceptEvent) { - if let id = waiters.keys.first, let continuation = waiters.removeValue(forKey: id) { - continuation.resume(returning: event) - } else { - acceptEvents.append(event) - } - } - - private func cancelAccept(_ id: UUID) { - waiters.removeValue(forKey: id)?.resume(returning: .closed) - } - - nonisolated private static func resolve( - _ event: AcceptEvent - ) throws -> (any CmxIrohConnection)? { - switch event { - case let .connection(connection): connection - case .failure: throw TestIrohTransportError.unsupported - case .closed: nil - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift deleted file mode 100644 index 6d20b5ea..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift +++ /dev/null @@ -1,529 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohEndpointSupervisorTests { - private let identity: CmxIrohPeerIdentity - - init() throws { - identity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "ab", count: 32) - ) - } - - @Test - func repeatedActivationReusesOneBoundGeneration() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let supervisor = try CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration() - ) - - let first = try await supervisor.activate() - let second = try await supervisor.activate() - - #expect(first == second) - #expect(first.state == .active) - #expect(first.runtimeGeneration == 1) - #expect(first.identity == identity) - #expect(await factory.observedConfigurations().count == 1) - } - - @Test - func deactivationInvalidatesAndClosesAnInFlightBindResult() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let factory = TestBlockingIrohEndpointFactory(endpoint: endpoint) - let supervisor = try CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration() - ) - var started = await factory.bindStartedEvents().makeAsyncIterator() - let activation = Task { try await supervisor.activate() } - _ = await started.next() - - await supervisor.deactivate() - await factory.release() - - await #expect(throws: CancellationError.self) { - try await activation.value - } - #expect(await endpoint.observedCloseCallCount() == 1) - await #expect(throws: CmxIrohEndpointSupervisorError.inactive) { - try await supervisor.activeEndpoint() - } - } - - @Test - func concurrentActivationSharesOneBindOperation() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let factory = TestBlockingIrohEndpointFactory(endpoint: endpoint) - let supervisor = try CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration() - ) - var started = await factory.bindStartedEvents().makeAsyncIterator() - let first = Task { try await supervisor.activate() } - let second = Task { try await supervisor.activate() } - _ = await started.next() - - await factory.release() - - let firstSnapshot = try await first.value - let secondSnapshot = try await second.value - #expect(firstSnapshot == secondSnapshot) - #expect(firstSnapshot.runtimeGeneration == 1) - #expect(await endpoint.observedCloseCallCount() == 0) - } - - @Test - func unexpectedDriverCloseRebindsWithSameSecretAndNewRuntimeGeneration() async throws { - let firstEndpoint = TestIrohEndpoint(identity: identity) - let secondEndpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory(endpoints: [firstEndpoint, secondEndpoint]) - let configuration = try endpointConfiguration() - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: configuration - ) - var events = await supervisor.events().makeAsyncIterator() - #expect(await events.next() == .snapshot(CmxIrohEndpointSnapshot( - runtimeGeneration: 0, - state: .inactive, - identity: nil - ))) - - _ = try await supervisor.activate() - #expect(await events.next() == .snapshot(CmxIrohEndpointSnapshot( - runtimeGeneration: 1, - state: .starting, - identity: nil - ))) - #expect(await events.next() == .snapshot(CmxIrohEndpointSnapshot( - runtimeGeneration: 1, - state: .active, - identity: identity - ))) - await firstEndpoint.emit(.closedUnexpectedly) - #expect(await events.next() == .snapshot(CmxIrohEndpointSnapshot( - runtimeGeneration: 2, - state: .starting, - identity: nil - ))) - #expect(await events.next() == .snapshot(CmxIrohEndpointSnapshot( - runtimeGeneration: 2, - state: .active, - identity: identity - ))) - #expect(await events.next() == .recovered(previousGeneration: 1, newGeneration: 2)) - - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 2) - #expect(configurations[0].secretKey == configurations[1].secretKey) - #expect(try await supervisor.activeEndpoint().identity() == identity) - } - - @Test - func foregroundHealthCheckPreservesAHealthyGeneration() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let supervisor = try CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration() - ) - let active = try await supervisor.activate() - - let checked = try await supervisor.ensureHealthy() - - #expect(checked == active) - #expect(await factory.observedConfigurations().count == 1) - #expect(await endpoint.observedCloseCallCount() == 0) - } - - @Test - func foregroundHealthCheckRecreatesAStaleGeneration() async throws { - let staleEndpoint = TestIrohEndpoint(identity: identity) - let replacementEndpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory( - endpoints: [staleEndpoint, replacementEndpoint] - ) - let supervisor = try CmxIrohEndpointSupervisor( - factory: factory, - configuration: endpointConfiguration() - ) - _ = try await supervisor.activate() - await staleEndpoint.setHealthy(false) - - let checked = try await supervisor.ensureHealthy() - - #expect(checked.state == .active) - #expect(checked.runtimeGeneration == 2) - #expect(await factory.observedConfigurations().count == 2) - #expect(await staleEndpoint.observedCloseCallCount() == 1) - #expect(try await supervisor.activeEndpoint().identity() == identity) - } - - @Test - func failedRelayRefreshPreservesLastKnownGoodBindConfiguration() async throws { - let firstEndpoint = TestIrohEndpoint(identity: identity) - let secondEndpoint = TestIrohEndpoint(identity: identity) - await firstEndpoint.setRelayUpdateShouldFail(true) - let factory = TestIrohEndpointFactory(endpoints: [firstEndpoint, secondEndpoint]) - let initialConfiguration = try endpointConfiguration() - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: initialConfiguration - ) - _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - - await #expect(throws: TestIrohTransportError.relayUpdateFailed) { - try await supervisor.replaceRelays([replacement]) - } - await supervisor.deactivate() - _ = try await supervisor.activate() - - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 2) - #expect(configurations[1].relays == initialConfiguration.relays) - } - - @Test - func successfulRelayRefreshPreservesRequiredBindPolicyForRecovery() async throws { - let firstEndpoint = TestIrohEndpoint(identity: identity) - let secondEndpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory(endpoints: [firstEndpoint, secondEndpoint]) - let bindPolicy = try CmxIrohEndpointBindPolicy.required( - CmxIrohBindAddress(ipAddress: "0.0.0.0", port: 49_152) - ) - let initial = try endpointConfiguration(bindPolicy: bindPolicy) - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: initial - ) - _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - - try await supervisor.replaceRelays([replacement]) - await supervisor.deactivate() - _ = try await supervisor.activate() - - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 2) - #expect(configurations[1].bindPolicy == bindPolicy) - } - - @Test("successful relay replacement publishes a reachability change") - func successfulRelayReplacementPublishesNetworkChange() async throws { - let relayHint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - source: .native, - privacyScope: .publicInternet - ) - let endpoint = TestIrohEndpoint( - identity: identity, - pathHintsAfterRelayReplacement: [relayHint] - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try endpointConfiguration() - ) - let changes = HostRuntimeLANRefreshRecorder() - let events = await supervisor.events() - let observation = Task { - for await event in events { - if case .networkChanged = event { - await changes.record() - } - } - } - _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - - try await supervisor.replaceRelays([replacement]) - - let emittedChange = await changes.waitForRefresh(timeout: .seconds(1)) - #expect( - emittedChange, - "A successful relay replacement must publish a network-change event" - ) - observation.cancel() - await supervisor.deactivate() - } - - @Test("relay credential rotation does not republish an unchanged address") - func unchangedRelayAddressDoesNotPublishNetworkChange() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try endpointConfiguration() - ) - let changes = HostRuntimeLANRefreshRecorder() - let events = await supervisor.events() - let observation = Task { - for await event in events { - if case .networkChanged = event { - await changes.record() - } - } - } - _ = try await supervisor.activate() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - - try await supervisor.replaceRelays([replacement]) - - let emittedChange = await changes.waitForRefresh(timeout: .milliseconds(50)) - #expect( - !emittedChange, - "Rotating credentials without changing the published address must not refresh policy" - ) - observation.cancel() - await supervisor.deactivate() - } - - @Test - func customProfileReplacementSurvivesEndpointRecovery() async throws { - let firstEndpoint = TestIrohEndpoint(identity: identity) - let secondEndpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory(endpoints: [firstEndpoint, secondEndpoint]) - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: try endpointConfiguration() - ) - _ = try await supervisor.activate() - let custom = try CmxIrohCustomRelayProfile( - relays: [ - CmxIrohCustomRelay( - url: "https://private.example.net:8443/", - authenticationToken: "private-token" - ), - ] - ) - let profile = CmxIrohEndpointRelayProfile(customProfile: custom) - - try await supervisor.replaceRelayProfile(profile) - await supervisor.deactivate() - _ = try await supervisor.activate() - - #expect(await firstEndpoint.observedRelayProfileUpdates() == [profile]) - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 2) - #expect(configurations[1].relayProfile == profile) - #expect(configurations[1].secretKey == configurations[0].secretKey) - } - - @Test - func supersededRelayRefreshCannotPoisonAReplacementGeneration() async throws { - let firstEndpoint = TestBlockingRelayUpdateEndpoint(identity: identity) - let secondEndpoint = TestIrohEndpoint(identity: identity) - let thirdEndpoint = TestIrohEndpoint(identity: identity) - let factory = TestIrohEndpointFactory( - endpoints: [firstEndpoint, secondEndpoint, thirdEndpoint] - ) - let initialConfiguration = try endpointConfiguration() - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: initialConfiguration - ) - _ = try await supervisor.activate() - var updateEvents = await firstEndpoint.updateEvents().makeAsyncIterator() - let replacement = try relayConfiguration( - url: "https://usw1-1.relay.lawrence.cmux.iroh.link/", - token: "bbbb" - ) - let refresh = Task { - try await supervisor.replaceRelays([replacement]) - } - _ = await updateEvents.next() - - await supervisor.deactivate() - _ = try await supervisor.activate() - await firstEndpoint.releaseUpdate() - await #expect(throws: CmxIrohEndpointSupervisorError.superseded) { - try await refresh.value - } - await supervisor.deactivate() - _ = try await supervisor.activate() - - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 3) - #expect(configurations[2].relays == initialConfiguration.relays) - } - - @Test("an already-online generation replays relay readiness") - func alreadyOnlineGenerationIsImmediatelyReady() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let supervisor = try CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: endpointConfiguration() - ) - _ = try await supervisor.activate() - await endpoint.emit(.online) - - try await supervisor.waitForUsableHomeRelay(timeout: .seconds(1)) - - #expect(await supervisor.hasUsableHomeRelay()) - } - - @Test("a strict custom relay profile participates in readiness") - func customRelayProfileWaitsForOnlineSignal() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let custom = try CmxIrohCustomRelayProfile(relays: [ - CmxIrohCustomRelay( - url: "https://private.example.net:8443/", - authenticationToken: "private-token" - ), - ]) - let base = try endpointConfiguration() - let configuration = CmxIrohEndpointConfiguration( - secretKey: base.secretKey, - alpns: base.alpns, - bindPolicy: base.bindPolicy, - relayProfile: CmxIrohEndpointRelayProfile(customProfile: custom) - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: configuration - ) - _ = try await supervisor.activate() - #expect(await supervisor.hasConfiguredRelay()) - let wait = Task { - try await supervisor.waitForUsableHomeRelay(timeout: .seconds(1)) - } - await Task.yield() - - await endpoint.emit(.online) - - try await wait.value - #expect(await supervisor.hasUsableHomeRelay()) - } - - @Test("relay replacement waits for the next online signal") - func relayReplacementWaitsForOnlineSignal() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let configuration = try endpointConfiguration() - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: configuration - ) - _ = try await supervisor.activate() - await endpoint.emit(.online) - try await supervisor.waitForUsableHomeRelay(timeout: .seconds(1)) - try await supervisor.replaceRelayProfile(configuration.relayProfile) - #expect(!(await supervisor.hasUsableHomeRelay())) - - let wait = Task { - try await supervisor.waitForUsableHomeRelay(timeout: .seconds(1)) - } - await Task.yield() - await endpoint.emit(.online) - - try await wait.value - #expect(await supervisor.hasUsableHomeRelay()) - } - - @Test("relay readiness has a bounded timeout") - func relayReadinessTimesOut() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let supervisor = try CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: endpointConfiguration() - ) - _ = try await supervisor.activate() - - await #expect(throws: CmxIrohEndpointSupervisorError.relayReadinessTimedOut) { - try await supervisor.waitForUsableHomeRelay(timeout: .milliseconds(20)) - } - } - - @Test("relay readiness cancellation removes its waiter") - func relayReadinessCancellationPropagates() async throws { - let endpoint = TestIrohEndpoint(identity: identity) - let supervisor = try CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: endpointConfiguration() - ) - _ = try await supervisor.activate() - let wait = Task { - try await supervisor.waitForUsableHomeRelay(timeout: .seconds(5)) - } - await Task.yield() - - wait.cancel() - - await #expect(throws: CancellationError.self) { - try await wait.value - } - } - - @Test("a replacement generation supersedes the prior relay waiter") - func replacementGenerationSupersedesRelayReadiness() async throws { - let first = TestIrohEndpoint(identity: identity) - let second = TestIrohEndpoint(identity: identity) - let supervisor = try CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [first, second]), - configuration: endpointConfiguration() - ) - _ = try await supervisor.activate() - let wait = Task { - try await supervisor.waitForUsableHomeRelay(timeout: .seconds(5)) - } - await Task.yield() - - await first.emit(.closedUnexpectedly) - - await #expect(throws: CmxIrohEndpointSupervisorError.superseded) { - try await wait.value - } - let replacementIdentity = try await supervisor.activeEndpoint().identity() - #expect(replacementIdentity == identity) - } - - private func endpointConfiguration( - bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral - ) throws -> CmxIrohEndpointConfiguration { - let relay = try relayConfiguration( - url: "https://use1-1.relay.lawrence.cmux.iroh.link/", - token: "aaaa" - ) - return try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - bindPolicy: bindPolicy, - managedRelayURLs: [ - relay.url, - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ], - relays: [relay] - ) - } - - private func relayConfiguration( - url: String, - token: String - ) throws -> CmxIrohRelayConfiguration { - let now = Date(timeIntervalSince1970: 1_000) - return try CmxIrohRelayConfiguration( - url: url, - token: token, - expiresAt: now.addingTimeInterval(24 * 60 * 60), - refreshAfter: now.addingTimeInterval(12 * 60 * 60), - now: now - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohGrantVerifierTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohGrantVerifierTests.swift deleted file mode 100644 index 7e0befad..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohGrantVerifierTests.swift +++ /dev/null @@ -1,297 +0,0 @@ -import CryptoKit -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohGrantVerifierTests { - @Test - func pairGrantBindsSignatureTimePlatformAndExactPeers() throws { - let fixture = try Fixture() - let token = try fixture.pairGrant(expiresAt: fixture.nowSeconds + 3_600) - - let claims = try CmxIrohGrantVerifier().verifyPairGrant( - token, - keys: fixture.keySet, - initiator: fixture.initiator, - acceptor: fixture.acceptor, - now: fixture.now - ) - #expect(claims.initiator.platform == .ios) - #expect(claims.acceptor.platform == .mac) - let liveClaims = try CmxIrohGrantVerifier().verifyPairGrant( - token, - keys: fixture.keySet, - authenticatedInitiatorID: fixture.initiator.endpointID, - acceptor: fixture.acceptor, - now: fixture.now - ) - #expect(liveClaims.initiator == fixture.initiator) - - let otherAcceptor = CmxIrohGrantPeer( - bindingID: fixture.acceptor.bindingID, - deviceID: fixture.acceptor.deviceID, - tag: "other", - platform: .mac, - endpointID: fixture.acceptor.endpointID, - identityGeneration: fixture.acceptor.identityGeneration - ) - #expect(throws: CmxIrohGrantVerifierError.identityMismatch) { - try CmxIrohGrantVerifier().verifyPairGrant( - token, - keys: fixture.keySet, - initiator: fixture.initiator, - acceptor: otherAcceptor, - now: fixture.now - ) - } - #expect(throws: CmxIrohGrantVerifierError.identityMismatch) { - try CmxIrohGrantVerifier().verifyPairGrant( - token, - keys: fixture.keySet, - authenticatedInitiatorID: fixture.acceptor.endpointID, - acceptor: fixture.acceptor, - now: fixture.now - ) - } - } - - @Test - func tamperingAndExpiryFailClosed() throws { - let fixture = try Fixture() - let valid = try fixture.pairGrant(expiresAt: fixture.nowSeconds + 60) - var segments = valid.split(separator: ".").map(String.init) - let replacement = segments[2].first == "A" ? "B" : "A" - segments[2].replaceSubrange(segments[2].startIndex ... segments[2].startIndex, with: replacement) - let tampered = segments.joined(separator: ".") - #expect(throws: CmxIrohGrantVerifierError.invalidSignature) { - try CmxIrohGrantVerifier().verifyPairGrant( - tampered, - keys: fixture.keySet, - initiator: fixture.initiator, - acceptor: fixture.acceptor, - now: fixture.now - ) - } - - let expired = try fixture.pairGrant(expiresAt: fixture.nowSeconds) - #expect(throws: CmxIrohGrantVerifierError.expired) { - try CmxIrohGrantVerifier().verifyPairGrant( - expired, - keys: fixture.keySet, - initiator: fixture.initiator, - acceptor: fixture.acceptor, - now: fixture.now - ) - } - } - - @Test - func offlinePairRequiresDistinctEndpointsAndConstantAccountSubject() throws { - let fixture = try Fixture() - let subject = Data(repeating: 7, count: 32).base64URL - let initiatorToken = try fixture.attestation( - expectation: fixture.initiatorExpectation, - subject: subject - ) - let acceptorToken = try fixture.attestation( - expectation: fixture.acceptorExpectation, - subject: subject - ) - let pair = try CmxIrohGrantVerifier().verifyOfflineSameAccountPair( - initiatorToken: initiatorToken, - acceptorToken: acceptorToken, - keys: fixture.keySet, - initiator: fixture.initiatorExpectation, - acceptor: fixture.acceptorExpectation, - now: fixture.now - ) - #expect(pair.initiator.accountSubject == pair.acceptor.accountSubject) - - let otherSubject = Data(repeating: 8, count: 32).base64URL - let mismatched = try fixture.attestation( - expectation: fixture.acceptorExpectation, - subject: otherSubject - ) - #expect(throws: CmxIrohGrantVerifierError.accountMismatch) { - try CmxIrohGrantVerifier().verifyOfflineSameAccountPair( - initiatorToken: initiatorToken, - acceptorToken: mismatched, - keys: fixture.keySet, - initiator: fixture.initiatorExpectation, - acceptor: fixture.acceptorExpectation, - now: fixture.now - ) - } - } - - @Test - func keySetRejectsWrongAlgorithmBeforeSignatureUse() throws { - let fixture = try Fixture() - let badKey = CmxIrohGrantVerificationKey( - kid: fixture.keySet.keys[0].kid, - alg: "ES256", - spkiDerBase64: fixture.keySet.keys[0].spkiDerBase64 - ) - let badSet = CmxIrohGrantVerificationKeySet( - version: 1, - currentKeyID: "current", - keys: [badKey] - ) - let token = try fixture.pairGrant(expiresAt: fixture.nowSeconds + 60) - #expect(throws: CmxIrohGrantVerifierError.invalidKeySet) { - try CmxIrohGrantVerifier().verifyPairGrant( - token, - keys: badSet, - initiator: fixture.initiator, - acceptor: fixture.acceptor, - now: fixture.now - ) - } - } -} - -private struct Fixture { - let privateKey: Curve25519.Signing.PrivateKey - let keySet: CmxIrohGrantVerificationKeySet - let initiator: CmxIrohGrantPeer - let acceptor: CmxIrohGrantPeer - let initiatorExpectation: CmxIrohEndpointExpectation - let acceptorExpectation: CmxIrohEndpointExpectation - let now = Date(timeIntervalSince1970: 1_800_000_000) - let nowSeconds: Int64 = 1_800_000_000 - - init() throws { - privateKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data((0 ..< 32).map(UInt8.init)) - ) - let prefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, - 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]) - let key = CmxIrohGrantVerificationKey( - kid: "current", - alg: "EdDSA", - spkiDerBase64: (prefix + privateKey.publicKey.rawRepresentation).base64EncodedString() - ) - keySet = CmxIrohGrantVerificationKeySet( - version: 1, - currentKeyID: "current", - keys: [key] - ) - let initiatorID = try CmxIrohPeerIdentity( - endpointID: privateKey.publicKey.rawRepresentation.hex - ) - let acceptorKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data(repeating: 9, count: 32) - ) - let acceptorID = try CmxIrohPeerIdentity( - endpointID: acceptorKey.publicKey.rawRepresentation.hex - ) - initiator = CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174001", - deviceID: "123e4567-e89b-42d3-a456-426614174002", - tag: "stable", - platform: .ios, - endpointID: initiatorID, - identityGeneration: 1 - ) - acceptor = CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174003", - deviceID: "123e4567-e89b-42d3-a456-426614174004", - tag: "stable", - platform: .mac, - endpointID: acceptorID, - identityGeneration: 2 - ) - initiatorExpectation = CmxIrohEndpointExpectation( - bindingID: initiator.bindingID, - deviceID: initiator.deviceID, - endpointID: initiator.endpointID, - identityGeneration: initiator.identityGeneration, - platform: initiator.platform - ) - acceptorExpectation = CmxIrohEndpointExpectation( - bindingID: acceptor.bindingID, - deviceID: acceptor.deviceID, - endpointID: acceptor.endpointID, - identityGeneration: acceptor.identityGeneration, - platform: acceptor.platform - ) - } - - func pairGrant(expiresAt: Int64) throws -> String { - let claims: [String: Any] = [ - "jti": "123e4567-e89b-42d3-a456-426614174010", - "iat": nowSeconds, - "nbf": nowSeconds - 5, - "exp": expiresAt, - "alpn": "cmux/mobile/1", - "scope": "cmux.mobile.attach", - "initiator": peerObject(initiator), - "acceptor": peerObject(acceptor), - ] - return try token(type: "cmux-pair-grant+jwt", claims: claims) - } - - func attestation( - expectation: CmxIrohEndpointExpectation, - subject: String - ) throws -> String { - let claims: [String: Any] = [ - "version": 1, - "jti": UUID().uuidString.lowercased(), - "sub": subject, - "bindingId": expectation.bindingID, - "deviceId": expectation.deviceID, - "endpointId": expectation.endpointID.endpointID, - "identityGeneration": expectation.identityGeneration, - "platform": expectation.platform.rawValue, - "iat": nowSeconds, - "nbf": nowSeconds - 5, - "exp": nowSeconds + 3_600, - "alpn": "cmux/mobile/1", - "scope": "cmux.offline-pair.same-account", - ] - return try token(type: "cmux-endpoint-attestation-v1+jwt", claims: claims) - } - - private func token(type: String, claims: [String: Any]) throws -> String { - let header = try JSONSerialization.data( - withJSONObject: ["alg": "EdDSA", "typ": type, "kid": "current"], - options: [.sortedKeys] - ).base64URL - let body = try JSONSerialization.data( - withJSONObject: claims, - options: [.sortedKeys] - ).base64URL - let input = "\(header).\(body)" - let signature = try privateKey.signature(for: Data(input.utf8)).base64URL - return "\(input).\(signature)" - } - - private func peerObject(_ peer: CmxIrohGrantPeer) -> [String: Any] { - [ - "bindingId": peer.bindingID, - "deviceId": peer.deviceID, - "tag": peer.tag, - "platform": peer.platform.rawValue, - "endpointId": peer.endpointID.endpointID, - "identityGeneration": peer.identityGeneration, - ] - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - var hex: String { - map { String(format: "%02x", $0) }.joined() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostPolicyCacheTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostPolicyCacheTests.swift deleted file mode 100644 index cf200893..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostPolicyCacheTests.swift +++ /dev/null @@ -1,282 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh offline host policy cache") -struct CmxIrohHostPolicyCacheTests { - @Test("verified policy survives cache recreation in device-only Keychain storage") - func roundTripsVerifiedPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let policy = try fixture.policy() - let cache = CmxIrohHostPolicyCache(secureStore: store) - - try await cache.save(policy, for: expectation, now: fixture.now) - - let recreated = CmxIrohHostPolicyCache(secureStore: store) - #expect( - try await recreated.load(for: expectation, now: fixture.now) == policy - ) - #expect( - try await recreated.load(for: expectation, now: fixture.now)?.lanRendezvous - == fixture.lanRendezvous - ) - #expect( - await store.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - } - - @Test("save verifies the attestation before replacing cached authority") - func saveRejectsWrongSigningKey() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let cache = CmxIrohHostPolicyCache(secureStore: store) - - await #expect(throws: CmxIrohGrantVerifierError.invalidSignature) { - try await cache.save( - fixture.policySignedByOriginalKey( - publishedKeySet: fixture.alternateKeySet - ), - for: expectation, - now: fixture.now - ) - } - #expect(await store.recordCount() == 0) - } - - @Test("save rejects an already expired signed attestation") - func saveRejectsExpiredPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let cache = CmxIrohHostPolicyCache(secureStore: store) - - await #expect(throws: CmxIrohGrantVerifierError.expired) { - try await cache.save( - fixture.policy( - expiresAt: fixture.now.addingTimeInterval(-1) - ), - for: expectation, - now: fixture.now - ) - } - #expect(await store.recordCount() == 0) - } - - @Test("expired policy is deleted and returned as a cache miss") - func loadDeletesExpiredPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: expectation, - now: fixture.now - ) - - #expect( - try await cache.load( - for: expectation, - now: fixture.now.addingTimeInterval(3_601) - ) == nil - ) - #expect(await store.recordCount() == 0) - } - - @Test("wrong account deletes the active policy instead of resurrecting it") - func loadDeletesWrongAccountPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: fixture.expectation(), - now: fixture.now - ) - - #expect( - try await cache.load( - for: fixture.expectation(accountID: "account-b"), - now: fixture.now - ) == nil - ) - #expect(await store.recordCount() == 0) - } - - @Test("wrong app instance deletes the active policy") - func loadDeletesWrongAppInstancePolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: fixture.expectation(), - now: fixture.now - ) - - #expect( - try await cache.load( - for: fixture.expectation( - appInstanceID: "123e4567-e89b-42d3-a456-426614174088" - ), - now: fixture.now - ) == nil - ) - #expect(await store.recordCount() == 0) - } - - @Test("wrong identity generation deletes the active policy") - func loadDeletesWrongGenerationPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: fixture.expectation(), - now: fixture.now - ) - - #expect( - try await cache.load( - for: fixture.expectation(identityGeneration: 5), - now: fixture.now - ) == nil - ) - #expect(await store.recordCount() == 0) - } - - @Test("wrong local EndpointID deletes the active policy") - func loadDeletesWrongEndpointPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: fixture.expectation(), - now: fixture.now - ) - - #expect( - try await cache.load( - for: fixture.expectation( - endpointID: CmxIrohPeerIdentity( - endpointID: String(repeating: "cd", count: 32) - ) - ), - now: fixture.now - ) == nil - ) - #expect(await store.recordCount() == 0) - } - - @Test("changed pairing policy deletes the active policy") - func loadDeletesChangedPairingPolicy() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: fixture.expectation(), - now: fixture.now - ) - - #expect( - try await cache.load( - for: fixture.expectation(pairingEnabled: false), - now: fixture.now - ) == nil - ) - #expect(await store.recordCount() == 0) - } - - @Test("wrong cached verification keyset is deleted") - func loadDeletesWrongVerificationKeySet() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: expectation, - now: fixture.now - ) - let account = try #require(await store.lastDeletedOrWrittenAccount()) - let encoded = try #require(await store.read(account: account)) - let corrupted = try replacingKeySets( - in: encoded, - with: fixture.alternateKeySet - ) - await store.seed(corrupted, account: account) - - #expect(try await cache.load(for: expectation, now: fixture.now) == nil) - #expect(await store.recordCount() == 0) - } - - @Test("corrupt records are deleted and returned as a cache miss") - func loadDeletesCorruptRecord() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: expectation, - now: fixture.now - ) - let account = try #require(await store.lastDeletedOrWrittenAccount()) - await store.seed(Data("not-json".utf8), account: account) - - #expect(try await cache.load(for: expectation, now: fixture.now) == nil) - #expect(await store.recordCount() == 0) - } - - @Test("scoped deletion and deactivation remove cached policy") - func explicitDeletion() async throws { - let fixture = try HostPolicyCacheTestFixture() - let store = TestSecureCredentialStore() - let expectation = try fixture.expectation() - let cache = CmxIrohHostPolicyCache(secureStore: store) - try await cache.save( - fixture.policy(), - for: expectation, - now: fixture.now - ) - - try await cache.delete(for: expectation) - #expect(await store.recordCount() == 0) - - try await cache.save( - fixture.policy(), - for: expectation, - now: fixture.now - ) - try await cache.deactivate() - #expect(await store.recordCount() == 0) - #expect(await store.deleteAllCount() == 1) - } - - private func replacingKeySets( - in data: Data, - with keySet: CmxIrohGrantVerificationKeySet - ) throws -> Data { - var root = try #require( - JSONSerialization.jsonObject(with: data) as? [String: Any] - ) - var policy = try #require(root["policy"] as? [String: Any]) - let encodedKeySet = try JSONEncoder().encode(keySet) - let keySetObject = try JSONSerialization.jsonObject(with: encodedKeySet) - policy["grantVerificationKeys"] = keySetObject - var attestation = try #require( - policy["endpointAttestation"] as? [String: Any] - ) - attestation["grant_verification_keys"] = keySetObject - policy["endpointAttestation"] = attestation - root["policy"] = policy - return try JSONSerialization.data(withJSONObject: root) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift deleted file mode 100644 index ccc6cdd6..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleRaceTests.swift +++ /dev/null @@ -1,297 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohHostRuntimeTests { - @Test("relay installation republishes the host's newly usable address") - func relayInstallationRepublishesNewlyAddressedHost() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now) - let refreshedBinding = try HostRuntimeFixture.binding( - endpointID: fixture.endpointID.endpointID, - bindingID: fixture.binding.bindingID, - publicHintObservedAt: now, - publicHintExpiresAt: now.addingTimeInterval(60 * 60) - ) - let relayHint = try #require(refreshedBinding.pathHints.first) - let refreshedDiscovery = try HostRuntimeFixture.discovery( - binding: refreshedBinding, - relays: HostRuntimeFixture.relayURLs - ) - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, - directAddresses: ["0.0.0.0:50909", "[::]:54750"], - pathHintsAfterRelayReplacement: [relayHint] - ) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationBindings: [refreshedBinding], - subsequentDiscoveries: [refreshedDiscovery] - ) - let publications = HostRuntimeBindingPublicationRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() }, - handleBinding: { registration, discovery, _ in - await publications.record( - registration: registration.binding, - discovery: discovery - ) - } - ) - try await runtime.start() - - let republished = await broker.waitForRegistrationCount( - 2, - timeout: .seconds(1) - ) - #expect( - republished, - "Installing the first usable relay address must request a fresh registration" - ) - guard republished else { - await runtime.stop() - return - } - - let registrations = await broker.observedPreparedRegistrations() - #expect(registrations.count == 2) - let initialHints = try registrationPathHints(registrations[0]) - let refreshedHints = try registrationPathHints(registrations[1]) - #expect(initialHints.isEmpty) - #expect(refreshedHints == [relayHint]) - let expectedDirectPorts = try CmxIrohDirectPorts( - ipv4: 50_909, - ipv6: 54_750 - ) - let initialDirectPorts = try registrationDirectPorts(registrations[0]) - let refreshedDirectPorts = try registrationDirectPorts(registrations[1]) - #expect(initialDirectPorts == expectedDirectPorts) - #expect(refreshedDirectPorts == expectedDirectPorts) - - let published = await publications.values() - #expect(published.count == 2) - #expect(published[0].registration.pathHints.isEmpty) - #expect(published[0].discovered.pathHints.isEmpty) - #expect(published[1].registration.pathHints == [relayHint]) - #expect(published[1].discovered.pathHints == [relayHint]) - #expect(published[1].registration.endpointID == fixture.endpointID) - #expect(published[1].registration.bindingID == fixture.binding.bindingID) - - let snapshot = await runtime.snapshot() - #expect(snapshot.state == .active) - #expect(snapshot.endpointID == fixture.endpointID) - #expect(snapshot.bindingID == fixture.binding.bindingID) - #expect(await endpoint.observedCloseCallCount() == 0) - await runtime.stop() - } - - @Test - func validatedBindingPublishesBeforeRelayCredentialInstallationCompletes() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeSuspensionGate() - let bindings = HostRuntimeBindingRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - relayIssueHook: { await gate.suspend() } - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleBinding: { _, _, _ in await bindings.record() } - ) - let start = Task { try await runtime.start() } - await gate.waitUntilSuspended() - - #expect(await bindings.count() == 1) - - await gate.resume() - try await start.value - await runtime.stop() - } - - @Test - func validatedBindingPublishesBeforeLANAdvertisementCompletes() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeSuspensionGate() - let bindings = HostRuntimeBindingRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleBinding: { _, _, _ in await bindings.record() }, - handleLANPolicy: { _, _ in await gate.suspend() } - ) - let start = Task { try await runtime.start() } - await gate.waitUntilSuspended() - - #expect(await bindings.count() == 1) - - await gate.resume() - try await start.value - await runtime.stop() - } - - @Test - func stoppedHostIgnoresSupersededRefreshFailure() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeRegistrationGate() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationHook: { await gate.waitOnce() } - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() } - ) - try await runtime.start() - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - let refresh = await runtime.registrationRefreshTask - - await runtime.stop() - await gate.open() - await refresh?.value - - #expect(await runtime.snapshot().state == .inactive) - #expect(await endpoint.observedCloseCallCount() == 1) - } - - @Test - func signedOutHostIgnoresSupersededRefreshFailure() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeRegistrationGate() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationHook: { await gate.waitOnce() } - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() } - ) - try await runtime.start() - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - let refresh = await runtime.registrationRefreshTask - - let preparation = await runtime.deactivateForSignOut() - await gate.open() - await refresh?.value - - #expect(preparation.wasPersisted) - #expect(await runtime.snapshot().state == .inactive) - #expect(await endpoint.observedCloseCallCount() == 1) - } -} - -private struct HostRuntimeBindingPublication: Equatable, Sendable { - let registration: CmxIrohBrokerBinding - let discovered: CmxIrohBrokerBinding -} - -private actor HostRuntimeBindingPublicationRecorder { - private var recorded: [HostRuntimeBindingPublication] = [] - - func record( - registration: CmxIrohBrokerBinding, - discovery: CmxIrohDiscoveryResponse - ) { - guard let discovered = discovery.bindings.first(where: { - $0.bindingID == registration.bindingID - }) else { return } - recorded.append( - HostRuntimeBindingPublication( - registration: registration, - discovered: discovered - ) - ) - } - - func values() -> [HostRuntimeBindingPublication] { recorded } -} - -private func registrationPathHints( - _ prepared: CmxIrohPreparedRegistration -) throws -> [CmxIrohPathHint] { - let value = prepared.encodedPayload - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padded = value + String(repeating: "=", count: (4 - value.count % 4) % 4) - let payload = try #require(Data(base64Encoded: padded)) - let object = try #require( - JSONSerialization.jsonObject(with: payload) as? [String: Any] - ) - let pathHints = try #require(object["pathHints"] as? [[String: Any]]) - let encodedHints = try JSONSerialization.data(withJSONObject: pathHints) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode([CmxIrohPathHint].self, from: encodedHints) -} - -private func registrationDirectPorts( - _ prepared: CmxIrohPreparedRegistration -) throws -> CmxIrohDirectPorts? { - let value = prepared.encodedPayload - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let padded = value + String(repeating: "=", count: (4 - value.count % 4) % 4) - let payload = try #require(Data(base64Encoded: padded)) - let object = try #require( - JSONSerialization.jsonObject(with: payload) as? [String: Any] - ) - guard let directPorts = object["directPorts"] else { return nil } - return try JSONDecoder().decode( - CmxIrohDirectPorts.self, - from: JSONSerialization.data(withJSONObject: directPorts) - ) -} - -private actor HostRuntimeSuspensionGate { - private var suspended = false - private var suspensionWaiters: [CheckedContinuation<Void, Never>] = [] - private var resumeWaiter: CheckedContinuation<Void, Never>? - - func suspend() async { - suspended = true - let waiters = suspensionWaiters - suspensionWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { resumeWaiter = $0 } - } - - func waitUntilSuspended() async { - if suspended { return } - await withCheckedContinuation { suspensionWaiters.append($0) } - } - - func resume() { - resumeWaiter?.resume() - resumeWaiter = nil - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests+Support.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests+Support.swift deleted file mode 100644 index 622da78c..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests+Support.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -final class HostRegistrationRenewalClock: CmxIrohRelayClock, @unchecked Sendable { - private let lock = NSLock() - private var date: Date - private var deadlines: [Date] = [] - private var sleepers: [UUID: CheckedContinuation<Void, any Error>] = [:] - private var sleepWaiters: [CheckedContinuation<Void, Never>] = [] - private var cancellationCount = 0 - - init(now: Date) { - date = now - } - - func now() -> Date { - lock.withLock { date } - } - - func sleep(until deadline: Date) async throws { - let id = UUID() - let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in - deadlines.append(deadline) - defer { sleepWaiters.removeAll() } - return sleepWaiters - } - for waiter in waiters { waiter.resume() } - try await withTaskCancellationHandler { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { continuation in - lock.withLock { sleepers[id] = continuation } - if Task.isCancelled { cancel(id: id) } - } - } onCancel: { - cancel(id: id) - } - } - - func advance(to newDate: Date) { - let continuations = lock.withLock { () -> [CheckedContinuation<Void, any Error>] in - date = newDate - defer { sleepers.removeAll() } - return Array(sleepers.values) - } - for continuation in continuations { continuation.resume() } - } - - func observedSleepDeadlines() -> [Date] { - lock.withLock { deadlines } - } - - func waitUntilSleeping() async { - await waitUntilSleepCount(1) - } - - func waitUntilSleepCount(_ count: Int) async { - let shouldWait = lock.withLock { deadlines.count < count } - guard shouldWait else { return } - await withCheckedContinuation { continuation in - let resumeNow = lock.withLock { () -> Bool in - if deadlines.count < count { - sleepWaiters.append(continuation) - return false - } - return true - } - if resumeNow { continuation.resume() } - } - } - - func observedCancellationCount() -> Int { - lock.withLock { cancellationCount } - } - - private func cancel(id: UUID) { - let continuation = lock.withLock { () -> CheckedContinuation<Void, any Error>? in - guard let continuation = sleepers.removeValue(forKey: id) else { return nil } - cancellationCount += 1 - return continuation - } - continuation?.resume(throwing: CancellationError()) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift deleted file mode 100644 index db2b4e82..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeLifecycleTests.swift +++ /dev/null @@ -1,496 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing - -@testable import CmuxIrohTransport - -extension CmxIrohHostRuntimeTests { - @Test - func unchangedReachabilityRenewsRegistrationBeforeHintExpiry() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ) - let clock = HostRegistrationRenewalClock(now: now) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { clock.now() }, - registrationClock: clock, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - await clock.waitUntilSleeping() - let renewalDeadline = try #require(clock.observedSleepDeadlines().first) - #expect(renewalDeadline < now.addingTimeInterval(60 * 60)) - - clock.advance(to: renewalDeadline) - await broker.waitForRegistrationCount(2) - - #expect(await broker.observedRegistrationCount() == 2) - await clock.waitUntilSleepCount(2) - await runtime.stop() - #expect(clock.observedCancellationCount() == 1) - } - - @Test - func registrationRenewalHonorsBrokerRetryAfterFloor() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationErrors: [ - .rateLimited(code: "slow_down", retryAfterSeconds: 300), - ] - ) - let clock = HostRegistrationRenewalClock(now: now) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { clock.now() }, - registrationClock: clock, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - await clock.waitUntilSleepCount(1) - let renewalDeadline = try #require(clock.observedSleepDeadlines().first) - clock.advance(to: renewalDeadline) - await broker.waitForRegistrationCount(2) - await clock.waitUntilSleepCount(2) - - let retryDeadline = try #require(clock.observedSleepDeadlines().last) - #expect(retryDeadline >= renewalDeadline.addingTimeInterval(300)) - await runtime.stop() - } - - @Test - func registrationRenewalBacksOffConsecutiveFailures() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationErrors: [.connectivity, .connectivity] - ) - let clock = HostRegistrationRenewalClock(now: now) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { clock.now() }, - registrationClock: clock, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - await clock.waitUntilSleepCount(1) - var deadline = try #require(clock.observedSleepDeadlines().last) - - clock.advance(to: deadline) - await broker.waitForRegistrationCount(2) - await clock.waitUntilSleepCount(2) - let firstRetry = try #require(clock.observedSleepDeadlines().last) - #expect(firstRetry.timeIntervalSince(deadline) >= 30) - - deadline = firstRetry - clock.advance(to: deadline) - await broker.waitForRegistrationCount(3) - await clock.waitUntilSleepCount(3) - let secondRetry = try #require(clock.observedSleepDeadlines().last) - #expect(secondRetry.timeIntervalSince(deadline) >= 60) - - await runtime.stop() - } - - @Test - func successfulRegistrationRenewalResetsBackoff() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now, publicHintLifetime: 60 * 60) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationErrors: [.connectivity] - ) - let clock = HostRegistrationRenewalClock(now: now) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - now: { clock.now() }, - registrationClock: clock, - registrationRetryJitter: { 0 }, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - await clock.waitUntilSleepCount(1) - var deadline = try #require(clock.observedSleepDeadlines().last) - clock.advance(to: deadline) - await broker.waitForRegistrationCount(2) - await clock.waitUntilSleepCount(2) - - deadline = try #require(clock.observedSleepDeadlines().last) - #expect(deadline.timeIntervalSince(clock.now()) == 30) - clock.advance(to: deadline) - await broker.waitForRegistrationCount(3) - await clock.waitUntilSleepCount(3) - - await broker.enqueueSubsequentRegistrationError(.connectivity) - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(4) - await clock.waitUntilSleepCount(4) - let resetRetry = try #require(clock.observedSleepDeadlines().last) - #expect(resetRetry.timeIntervalSince(clock.now()) == 30) - - await runtime.stop() - } - - @Test - func startBindsExactRegisteredIdentityAndStopClosesIt() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, - directAddresses: ["192.168.1.10:50906"] - ) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ) - let deactivations = HostRuntimeDeactivationRecorder() - let lanPolicies = HostRuntimeLANPolicyRecorder() - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleDeactivation: { bindingID in - await deactivations.record(bindingID) - }, - handleLANPolicy: { context, directAddresses in - await lanPolicies.record( - context: context, - directAddresses: await directAddresses() - ) - } - ) - - try await runtime.start() - - let snapshot = await runtime.snapshot() - #expect(snapshot.state == .active) - #expect(snapshot.endpointID == fixture.endpointID) - #expect(snapshot.bindingID == fixture.binding.bindingID) - #expect(await broker.observedRegistrationCount() == 1) - let configurations = await factory.observedConfigurations() - #expect(configurations.count == 1) - #expect(configurations.first?.secretKey == fixture.identity.secretKey) - #expect(configurations.first?.bindPolicy == .ephemeral) - #expect(configurations.first?.managedRelayURLs == fixture.managedRelays) - let lan = try #require(await runtime.lanAdvertisementContext()) - #expect(lan.binding == CmxIrohBrokerBindingMetadata(binding: fixture.binding)) - #expect(lan.rendezvous == fixture.discovery.lanRendezvous) - #expect(await runtime.localDirectAddresses() == ["192.168.1.10:50906"]) - await lanPolicies.waitForCount(1) - #expect(await lanPolicies.contexts() == [lan]) - #expect(await lanPolicies.addresses() == [["192.168.1.10:50906"]]) - - await runtime.stop() - - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await deactivations.values() == [fixture.binding.bindingID]) - #expect(await runtime.snapshot().state == .inactive) - #expect(await runtime.lanAdvertisementContext() == nil) - } - - @Test - func suspendedSignOutPersistenceStopsPendingAdmissionAndBlocksRestart() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = HostRuntimeAcceptingEndpoint(identity: fixture.endpointID) - let store = TestControllableSecureCredentialStore() - let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) - let ordering = HostRuntimeSignOutOrderingRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ), - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - handleTransport: { session, _ in await session.close() }, - handleDeactivation: { bindingID in - let queued = try? await pendingRevocations.pending( - accountID: fixture.configuration.accountID - ).contains(where: { $0.bindingID == bindingID }) - await ordering.record( - endpointClosed: await endpoint.observedCloseCallCount() == 1, - revocationQueued: queued == true - ) - } - ) - try await runtime.start() - - let blockedReceive = TestBlockingIrohReceiveStream(buffer: Data()) - var blockedEvents = await blockedReceive.blockedEvents().makeAsyncIterator() - let connection = TestIrohConnection( - remoteIdentity: try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ), - bidirectionalStreams: [ - CmxIrohBidirectionalStream( - receiveStream: blockedReceive, - sendStream: TestIrohSendStream() - ), - ] - ) - await endpoint.enqueue(connection) - _ = await blockedEvents.next() - await store.suspendNextWrite() - - let signOut = Task { await runtime.deactivateForSignOut() } - await store.waitUntilWriteIsSuspended() - await connection.waitUntilClosed() - - let signingOut = await runtime.snapshot() - #expect(signingOut.state == .signingOut) - #expect(signingOut.bindingID == fixture.binding.bindingID) - #expect(await connection.observedCloseCallCount() > 0) - await #expect(throws: CmxIrohHostRuntimeError.alreadyActive) { - try await runtime.start() - } - - await store.resumeSuspendedWrite() - let preparation = await signOut.value - #expect(preparation.wasPersisted) - #expect(await ordering.values() == ["true:true"]) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func failedSignOutPersistenceClosesHostAndQuarantinesLocalState() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let store = TestControllableSecureCredentialStore() - let pendingRevocations = CmxIrohPendingRevocationOutbox(secureStore: store) - let deactivations = HostRuntimeDeactivationRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ), - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - handleTransport: { session, _ in await session.close() }, - handleDeactivation: { bindingID in - await deactivations.record(bindingID) - } - ) - try await runtime.start() - await store.failNextWrite() - - let preparation = await runtime.deactivateForSignOut() - - #expect(preparation.bindingID == fixture.binding.bindingID) - #expect(!preparation.wasPersisted) - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await deactivations.values() == [fixture.binding.bindingID]) - let quarantined = await runtime.snapshot() - #expect(quarantined.state == .quarantined) - #expect(quarantined.endpointID == nil) - #expect(quarantined.bindingID == fixture.binding.bindingID) - #expect(await runtime.lanAdvertisementContext() == nil) - await #expect(throws: CmxIrohHostRuntimeError.alreadyActive) { - try await runtime.start() - } - - let retried = await runtime.deactivateForSignOut() - #expect(retried.wasPersisted) - #expect(await deactivations.values() == [fixture.binding.bindingID]) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test - func requiredBindPolicyIsForwardedToTheEndpointGeneration() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ) - let bindAddress = try CmxIrohBindAddress( - ipAddress: "127.0.0.1", - port: 4_444 - ) - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - bindPolicy: .required(bindAddress) - ), - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - - #expect( - await factory.observedConfigurations().first?.bindPolicy - == .required(bindAddress) - ) - await runtime.stop() - } - - @Test - func connectivityFailureUsesVerifiedCacheOnlyAfterOnlineAttempt() async throws { - let fixture = try HostRuntimeFixture() - let cachedFixture = try fixture.cachedPolicyFixture() - let now = cachedFixture.now - let cachedPolicy = try cachedFixture.policy() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .connectivity - ) - let bindings = HostRuntimeBindingRecorder() - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration(cachedHostPolicy: cachedPolicy), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() }, - handleBinding: { _, _, _ in await bindings.record() } - ) - - try await runtime.start() - - #expect(await broker.observedRegistrationCount() == 1) - #expect(await runtime.snapshot().bindingID == cachedPolicy.binding.bindingID) - #expect(await runtime.lanAdvertisementContext()?.rendezvous == cachedPolicy.lanRendezvous) - #expect(await bindings.count() == 0) - await runtime.stop() - } - - @Test - func endpointNetworkChangeRequestsImmediateLANRefresh() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let recorder = HostRuntimeLANRefreshRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleLANRefresh: { await recorder.record() } - ) - try await runtime.start() - - await endpoint.emit(.networkChanged) - #expect(await recorder.waitForRefresh(timeout: .seconds(1))) - - #expect(await recorder.count() == 1) - await runtime.stop() - } - - @Test - func endpointOnlineRequestsImmediateReachabilityRefresh() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let recorder = HostRuntimeLANRefreshRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleLANRefresh: { await recorder.record() } - ) - try await runtime.start() - - await endpoint.emit(.online) - - #expect(await recorder.waitForRefresh(timeout: .seconds(1))) - await runtime.stop() - } - - @Test(arguments: [ - CmxIrohTrustBrokerClientError.rejected( - statusCode: 408, - code: "request_timeout" - ), - .rejected(statusCode: 425, code: "too_early"), - CmxIrohTrustBrokerClientError.rejected( - statusCode: 429, - code: "challenge_rate_limited" - ), - .rejected(statusCode: 503, code: "unavailable"), - ]) - func unavailableRegistrationRefreshPreservesActiveEndpoint( - _ failure: CmxIrohTrustBrokerClientError - ) async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationErrors: [failure] - ) - let deactivations = HostRuntimeDeactivationRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleDeactivation: { bindingID in - await deactivations.record(bindingID) - } - ) - try await runtime.start() - - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - await runtime.waitForRegistrationRefreshForTesting() - - #expect(await runtime.snapshot().state == .active) - #expect(await endpoint.observedCloseCallCount() == 0) - #expect(await deactivations.values().isEmpty) - await runtime.stop() - } - -} - -extension CmxIrohHostRuntime { - func waitForRegistrationRefreshForTesting() async { - await registrationRefreshTask?.value - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift deleted file mode 100644 index d2812395..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift +++ /dev/null @@ -1,364 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing - -@testable import CmuxIrohTransport - -extension CmxIrohHostRuntimeTests { - @Test - func unauthorizedRegistrationRefreshDeactivatesActiveEndpoint() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationErrors: [ - .rejected(statusCode: 401, code: "unauthorized"), - ] - ) - let deactivations = HostRuntimeDeactivationRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleDeactivation: { bindingID in - await deactivations.record(bindingID) - } - ) - try await runtime.start() - - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - await deactivations.waitForCount(1) - - #expect(await runtime.snapshot().state == .failed) - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await deactivations.values() == [fixture.binding.bindingID]) - } - - @Test - func networkChangeDuringRegistrationIsObservedAfterStartup() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let recorder = HostRuntimeLANRefreshRecorder() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationHook: { - await endpoint.emit(.networkChanged) - return await recorder.waitForRefresh(timeout: .seconds(1)) - } - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleLANRefresh: { await recorder.record() } - ) - - try await runtime.start() - - #expect(await broker.observedRegistrationHookResult() == true) - #expect(await recorder.count() == 1) - await runtime.stop() - } - - @Test - func networkChangeDuringActiveRefreshRequestsAnotherRegistration() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let gate = HostRuntimeRegistrationGate() - let refreshes = HostRuntimeLANRefreshRecorder() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentRegistrationHook: { await gate.waitOnce() } - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleLANRefresh: { await refreshes.record() } - ) - try await runtime.start() - - await endpoint.emit(.networkChanged) - await broker.waitForRegistrationCount(2) - await endpoint.emit(.networkChanged) - #expect(await refreshes.waitForCount(2, timeout: .seconds(1))) - await gate.open() - - let registeredAgain = await broker.waitForRegistrationCount( - 3, - timeout: .seconds(1) - ) - #expect(registeredAgain) - await runtime.stop() - } - - @Test - func refreshedVerifiedRendezvousReplacesPublishedLANPolicy() async throws { - let fixture = try HostRuntimeFixture() - let refreshedDiscovery = try HostRuntimeFixture.discovery( - binding: fixture.binding, - relays: Array(fixture.managedRelays), - lanGeneration: 2 - ) - let endpoint = TestIrohEndpoint( - identity: fixture.endpointID, - directAddresses: ["192.168.1.10:50906"] - ) - let policies = HostRuntimeLANPolicyRecorder() - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - subsequentDiscoveries: [refreshedDiscovery] - ), - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - handleTransport: { session, _ in await session.close() }, - handleLANPolicy: { context, directAddresses in - await policies.record( - context: context, - directAddresses: await directAddresses() - ) - } - ) - try await runtime.start() - await endpoint.emit(.networkChanged) - await policies.waitForCount(2) - - #expect(await policies.contexts().map(\.rendezvous.generation) == [1, 2]) - #expect(await policies.addresses() == [ - ["192.168.1.10:50906"], - ["192.168.1.10:50906"], - ]) - #expect(await runtime.lanAdvertisementContext()?.rendezvous.generation == 2) - await runtime.stop() - } - - @Test(arguments: [ - CmxIrohTrustBrokerClientError.missingAuthentication, - .rejected(statusCode: 400, code: "invalid_request"), - .invalidResponse, - ]) - func terminalBrokerFailureNeverUsesCachedPolicy( - _ failure: CmxIrohTrustBrokerClientError - ) async throws { - let fixture = try HostRuntimeFixture() - let cachedFixture = try fixture.cachedPolicyFixture() - let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: failure - ) - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - cachedHostPolicy: try cachedFixture.policy() - ), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() } - ) - - do { - try await runtime.start() - Issue.record("Expected terminal broker failure") - } catch let error as CmxIrohTrustBrokerClientError { - #expect(error == failure) - } - - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await runtime.snapshot().state == .failed) - } - - @Test - func onlinePolicySupersedesAValidCachedBinding() async throws { - let fixture = try HostRuntimeFixture() - let cachedMetadata = try CmxIrohBrokerBindingMetadata( - bindingID: "123e4567-e89b-42d3-a456-426614174099", - deviceID: fixture.binding.deviceID, - appInstanceID: fixture.binding.appInstanceID, - tag: fixture.binding.tag, - platform: .mac, - endpointID: fixture.binding.endpointID, - identityGeneration: fixture.binding.identityGeneration - ) - let cachedFixture = try fixture.cachedPolicyFixture(binding: cachedMetadata) - let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ) - let bindings = HostRuntimeBindingRecorder() - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - cachedHostPolicy: try cachedFixture.policy() - ), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() }, - handleBinding: { _, _, _ in await bindings.record() } - ) - - try await runtime.start() - - #expect(await runtime.snapshot().bindingID == fixture.binding.bindingID) - #expect(await bindings.count() == 1) - await runtime.stop() - } - - @Test - func forgedCachedPolicyFailsAfterConnectivityFailure() async throws { - let fixture = try HostRuntimeFixture() - let cachedFixture = try fixture.cachedPolicyFixture() - let now = cachedFixture.now - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .connectivity - ) - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - cachedHostPolicy: try cachedFixture.policySignedByOriginalKey( - publishedKeySet: cachedFixture.alternateKeySet - ) - ), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() } - ) - - await #expect(throws: CmxIrohGrantVerifierError.invalidSignature) { - try await runtime.start() - } - - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await runtime.snapshot().state == .failed) - } - - @Test - func confirmedOnlineBindingChangePreventsDiscoveryConnectivityFallback() async throws { - let fixture = try HostRuntimeFixture() - let cachedFixture = try fixture.cachedPolicyFixture() - let now = cachedFixture.now - let changedBinding = try HostRuntimeFixture.binding( - endpointID: fixture.endpointID.endpointID, - bindingID: "123e4567-e89b-42d3-a456-426614174099" - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: changedBinding, - discovery: fixture.discovery, - discoveryError: .connectivity - ) - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - cachedHostPolicy: try cachedFixture.policy() - ), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() } - ) - - await #expect(throws: CmxIrohHostRuntimeError.invalidLocalBinding) { - try await runtime.start() - } - - #expect(await endpoint.observedCloseCallCount() == 1) - } - - @Test - func routeContractMismatchNeverUsesCachedPolicy() async throws { - let fixture = try HostRuntimeFixture() - let cachedFixture = try fixture.cachedPolicyFixture() - let now = cachedFixture.now - let mismatchedDiscovery = try HostRuntimeFixture.discovery( - binding: fixture.binding, - relays: Array(fixture.managedRelays), - routeContractVersion: 2 - ) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: mismatchedDiscovery - ) - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - cachedHostPolicy: try cachedFixture.policy() - ), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() } - ) - - await #expect(throws: CmxIrohHostRuntimeError.routeContractMismatch) { - try await runtime.start() - } - - #expect(await endpoint.observedCloseCallCount() == 1) - } - - @Test - func discoverySubstitutionFailsClosedAndClosesEndpoint() async throws { - let fixture = try HostRuntimeFixture() - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let substituted = try HostRuntimeFixture.discovery( - binding: fixture.binding, - relays: Array(fixture.managedRelays), - overrideDeviceID: "123e4567-e89b-42d3-a456-426614174099" - ) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: substituted - ) - let cachedFixture = try fixture.cachedPolicyFixture() - let now = cachedFixture.now - let runtime = CmxIrohHostRuntime( - factory: factory, - broker: broker, - configuration: fixture.configuration( - cachedHostPolicy: try cachedFixture.policy() - ), - pendingRevocations: fixture.pendingRevocations(), - now: { now }, - handleTransport: { session, _ in await session.close() } - ) - - await #expect(throws: CmxIrohHostRuntimeError.invalidLocalBinding) { - try await runtime.start() - } - - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await runtime.snapshot().state == .failed) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift deleted file mode 100644 index 74b320aa..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTestSupport.swift +++ /dev/null @@ -1,192 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -@testable import CmuxIrohTransport - -struct HostRuntimeFixture { - let identity: CmxIrohIdentityMaterial - let endpointID: CmxIrohPeerIdentity - let binding: CmxIrohBrokerBinding - let discovery: CmxIrohDiscoveryResponse - let managedRelays: Set<String> - let configuration: CmxIrohHostRuntimeConfiguration - - init( - now: Date = Date(timeIntervalSince1970: 1_800_000_000), - publicHintLifetime: TimeInterval? = nil - ) throws { - let secret = Data(repeating: 0x31, count: 32) - identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: secret), - generation: 4 - ) - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: secret) - endpointID = try CmxIrohPeerIdentity( - endpointID: privateKey.publicKey.rawRepresentation - .map { String(format: "%02x", $0) } - .joined() - ) - managedRelays = Set(Self.relayURLs) - binding = try Self.binding( - endpointID: endpointID.endpointID, - publicHintObservedAt: publicHintLifetime == nil ? nil : now, - publicHintExpiresAt: publicHintLifetime.map(now.addingTimeInterval) - ) - discovery = try Self.discovery( - binding: binding, - relays: Self.relayURLs - ) - configuration = CmxIrohHostRuntimeConfiguration( - accountID: "account-a", - deviceID: binding.deviceID, - appInstanceID: binding.appInstanceID, - tag: binding.tag, - displayName: binding.displayName, - identity: identity, - pairingEnabled: binding.pairingEnabled, - capabilities: binding.capabilities, - managedRelayURLs: managedRelays - ) - } - - func configuration( - cachedHostPolicy: CmxIrohCachedHostPolicy? = nil, - bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - endpointRelayProfile: CmxIrohEndpointRelayProfile? = nil - ) -> CmxIrohHostRuntimeConfiguration { - CmxIrohHostRuntimeConfiguration( - accountID: configuration.accountID, - deviceID: binding.deviceID, - appInstanceID: binding.appInstanceID, - tag: binding.tag, - displayName: binding.displayName, - identity: identity, - pairingEnabled: binding.pairingEnabled, - capabilities: binding.capabilities, - bindPolicy: bindPolicy, - managedRelayURLs: managedRelays, - endpointRelayProfile: endpointRelayProfile, - cachedHostPolicy: cachedHostPolicy - ) - } - - func cachedPolicyFixture( - binding: CmxIrohBrokerBindingMetadata? = nil - ) throws -> HostPolicyCacheTestFixture { - try HostPolicyCacheTestFixture( - binding: binding ?? CmxIrohBrokerBindingMetadata(binding: self.binding), - pairingEnabled: self.binding.pairingEnabled, - capabilities: self.binding.capabilities - ) - } - - func pendingRevocations() -> CmxIrohPendingRevocationOutbox { - CmxIrohPendingRevocationOutbox( - secureStore: TestSecureCredentialStore() - ) - } - - static let relayURLs = [ - "https://aps1-1.relay.lawrence.cmux.iroh.link/", - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ] - - static func binding( - endpointID: String, - bindingID: String = "123e4567-e89b-42d3-a456-426614174010", - publicHintObservedAt: Date? = nil, - publicHintExpiresAt: Date? = nil - ) throws -> CmxIrohBrokerBinding { - try JSONDecoder().decode( - CmxIrohBrokerBinding.self, - from: bindingJSON( - endpointID: endpointID, - bindingID: bindingID, - publicHintObservedAt: publicHintObservedAt, - publicHintExpiresAt: publicHintExpiresAt - ) - ) - } - - static func discovery( - binding: CmxIrohBrokerBinding, - relays: [String], - overrideDeviceID: String? = nil, - routeContractVersion: Int = 1, - lanGeneration: Int = 1 - ) throws -> CmxIrohDiscoveryResponse { - var bindingObject = try JSONSerialization.jsonObject( - with: JSONEncoder().encode(binding) - ) as? [String: Any] ?? [:] - bindingObject["device_id"] = overrideDeviceID ?? binding.deviceID - let object: [String: Any] = [ - "route_contract_version": routeContractVersion, - "bindings": [bindingObject], - "relay_fleet": relays, - "lan_rendezvous": [ - "generation": lanGeneration, - "key": Data(repeating: 0, count: 32).base64URL, - ], - "grant_verification_keys": [ - "version": 1, - "current_kid": "test-key", - "keys": [[ - "kid": "test-key", - "alg": "EdDSA", - "spki_der_base64": "AA==", - ]], - ], - ] - return try JSONDecoder().decode( - CmxIrohDiscoveryResponse.self, - from: JSONSerialization.data(withJSONObject: object) - ) - } - - private static func bindingJSON( - endpointID: String, - bindingID: String = "123e4567-e89b-42d3-a456-426614174010", - deviceID: String = "123e4567-e89b-42d3-a456-426614174011", - publicHintObservedAt: Date? = nil, - publicHintExpiresAt: Date? = nil - ) throws -> Data { - let pathHints: [[String: Any]] - if let publicHintObservedAt, let publicHintExpiresAt { - pathHints = [[ - "kind": "relay_url", - "value": "https://use1-1.relay.lawrence.cmux.iroh.link/", - "source": "native", - "privacy_scope": "public_internet", - "observed_at": publicHintObservedAt.timeIntervalSinceReferenceDate, - "expires_at": publicHintExpiresAt.timeIntervalSinceReferenceDate, - ]] - } else { - pathHints = [] - } - return try JSONSerialization.data(withJSONObject: [ - "binding_id": bindingID, - "device_id": deviceID, - "app_instance_id": "123e4567-e89b-42d3-a456-426614174012", - "tag": "cmux-ios-v0", - "platform": "mac", - "display_name": "Test Mac", - "endpoint_id": endpointID, - "identity_generation": 4, - "pairing_enabled": true, - "capabilities": ["rpc", "multistream"], - "path_hints": pathHints, - "last_seen_at": "2026-07-09T12:00:00.000Z", - ]) - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift deleted file mode 100644 index 03a2daf9..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +++ /dev/null @@ -1,620 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohHostRuntimeTests { - @Test("direct-only startup does not wait for relay readiness") - func directOnlyStartupSkipsRelayReadiness() async throws { - let fixture = try HostRuntimeFixture() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration( - endpointRelayProfile: .unavailableManagedSelection - ), - pendingRevocations: fixture.pendingRevocations(), - protocolConfiguration: .testDirectOnlyApplicationLanes, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - - #expect(await runtime.snapshot().state == .active) - #expect(await broker.observedRelayIssueCount() == 0) - await runtime.stop() - } - - @Test("cold start retries transient broker connectivity before becoming active") - func coldStartRetriesTransientBrokerConnectivity() async throws { - let fixture = try HostRuntimeFixture() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .connectivity - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - registrationClock: ImmediateHostActivationClock(), - registrationRetrySchedule: CmxIrohRetrySchedule( - initialDelay: 1, - maximumDelay: 1, - jitterFraction: 0 - ), - registrationRetryJitter: { 0 }, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - - #expect(await broker.observedRegistrationCount() == 2) - #expect(await runtime.snapshot().state == .active) - await runtime.stop() - } - - @Test("cold start retries transient broker service failures before becoming active") - func coldStartRetriesTransientBrokerServiceFailure() async throws { - let fixture = try HostRuntimeFixture() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .rejected(statusCode: 503, code: "unavailable") - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - registrationClock: ImmediateHostActivationClock(), - registrationRetrySchedule: CmxIrohRetrySchedule( - initialDelay: 1, - maximumDelay: 1, - jitterFraction: 0 - ), - registrationRetryJitter: { 0 }, - handleTransport: { session, _ in await session.close() } - ) - - try await runtime.start() - - #expect(await broker.observedRegistrationCount() == 2) - #expect(await runtime.snapshot().state == .active) - await runtime.stop() - } - - @Test("cold start does not retry an untrusted broker response") - func coldStartDoesNotRetryInvalidBrokerResponse() async throws { - let fixture = try HostRuntimeFixture() - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .invalidResponse - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - registrationClock: ImmediateHostActivationClock(), - handleTransport: { session, _ in await session.close() } - ) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - try await runtime.start() - } - - #expect(await broker.observedRegistrationCount() == 1) - #expect(await runtime.snapshot().state == .failed) - } - - @Test("stopping during cold-start backoff prevents a stale registration retry") - func stopDuringColdStartBackoffPreventsRetry() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now) - let clock = HostRegistrationRenewalClock(now: now) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .connectivity - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - registrationClock: clock, - registrationRetrySchedule: CmxIrohRetrySchedule( - initialDelay: 1, - maximumDelay: 1, - jitterFraction: 0 - ), - registrationRetryJitter: { 0 }, - handleTransport: { session, _ in await session.close() } - ) - let start = Task { - try await runtime.start() - } - - await clock.waitUntilSleeping() - let deadline = try #require(clock.observedSleepDeadlines().first) - await runtime.stop() - clock.advance(to: deadline) - - await #expect(throws: CmxIrohHostRuntimeError.superseded) { - try await start.value - } - #expect(await broker.observedRegistrationCount() == 1) - #expect(await runtime.snapshot().state == .inactive) - } - - @Test("cancelling cold-start backoff cancels its delay and closes the endpoint") - func cancellingColdStartBackoffCancelsDelay() async throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let fixture = try HostRuntimeFixture(now: now) - let clock = HostRegistrationRenewalClock(now: now) - let endpoint = TestIrohEndpoint(identity: fixture.endpointID) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - registrationError: .connectivity - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: fixture.pendingRevocations(), - registrationClock: clock, - registrationRetrySchedule: CmxIrohRetrySchedule( - initialDelay: 7, - maximumDelay: 7, - jitterFraction: 0 - ), - registrationRetryJitter: { 0 }, - handleTransport: { session, _ in await session.close() } - ) - let start = Task { - try await runtime.start() - } - - await clock.waitUntilSleeping() - #expect(clock.observedSleepDeadlines() == [now.addingTimeInterval(7)]) - start.cancel() - - await #expect(throws: CancellationError.self) { - try await start.value - } - #expect(clock.observedCancellationCount() == 1) - #expect(await broker.observedRegistrationCount() == 1) - #expect(await endpoint.observedCloseCallCount() == 1) - #expect(await runtime.snapshot().state == .failed) - } - - @Test - func pendingRevocationFailureBlocksHostRegistrationAndCachedFallback() async throws { - let fixture = try HostRuntimeFixture() - let pendingRevocations = CmxIrohPendingRevocationOutbox( - secureStore: TestSecureCredentialStore() - ) - let pending = try CmxIrohPendingRevocation( - accountID: fixture.configuration.accountID, - tag: "older-build", - bindingID: "123e4567-e89b-42d3-a456-426614174099" - ) - try await pendingRevocations.enqueue(pending) - let broker = TestIrohHostBroker( - registrationBinding: fixture.binding, - discovery: fixture.discovery, - revokeError: .connectivity - ) - let runtime = CmxIrohHostRuntime( - factory: TestIrohEndpointFactory( - endpoints: [TestIrohEndpoint(identity: fixture.endpointID)] - ), - broker: broker, - configuration: fixture.configuration, - pendingRevocations: pendingRevocations, - handleTransport: { session, _ in await session.close() } - ) - - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { - try await runtime.start() - } - - #expect(await broker.observedRegistrationCount() == 0) - #expect(await broker.observedRevokedBindingIDs() == [pending.bindingID]) - #expect( - try await pendingRevocations.pending( - accountID: fixture.configuration.accountID - ) == [pending] - ) - } - -} - -actor TestIrohHostBroker: CmxIrohHostBrokerServing { - private var registrationBindings: [CmxIrohBrokerBinding] - private var discoveryResponses: [CmxIrohDiscoveryResponse] - private let registrationError: CmxIrohTrustBrokerClientError? - private let discoveryError: CmxIrohTrustBrokerClientError? - private let revokeError: CmxIrohTrustBrokerClientError? - private let registrationHook: (@Sendable () async -> Bool)? - private let subsequentRegistrationHook: (@Sendable () async -> Void)? - private let relayIssueHook: (@Sendable () async -> Void)? - private var subsequentRegistrationErrors: [CmxIrohTrustBrokerClientError] - private var registrationCount = 0 - private var preparedRegistrations: [CmxIrohPreparedRegistration] = [] - private var relayIssueCount = 0 - private var registrationHookResult: Bool? - private var revokedBindingIDs: [String] = [] - private var registrationCountWaiters: [ - UUID: (minimum: Int, continuation: CheckedContinuation<Void, Never>) - ] = [:] - - init( - registrationBinding: CmxIrohBrokerBinding, - discovery: CmxIrohDiscoveryResponse, - subsequentRegistrationBindings: [CmxIrohBrokerBinding] = [], - subsequentDiscoveries: [CmxIrohDiscoveryResponse] = [], - registrationError: CmxIrohTrustBrokerClientError? = nil, - discoveryError: CmxIrohTrustBrokerClientError? = nil, - revokeError: CmxIrohTrustBrokerClientError? = nil, - registrationHook: (@Sendable () async -> Bool)? = nil, - subsequentRegistrationHook: (@Sendable () async -> Void)? = nil, - relayIssueHook: (@Sendable () async -> Void)? = nil, - subsequentRegistrationErrors: [CmxIrohTrustBrokerClientError] = [] - ) { - registrationBindings = [registrationBinding] + subsequentRegistrationBindings - discoveryResponses = [discovery] + subsequentDiscoveries - self.registrationError = registrationError - self.discoveryError = discoveryError - self.revokeError = revokeError - self.registrationHook = registrationHook - self.subsequentRegistrationHook = subsequentRegistrationHook - self.relayIssueHook = relayIssueHook - self.subsequentRegistrationErrors = subsequentRegistrationErrors - } - - func register( - prepared: CmxIrohPreparedRegistration, - signer _: CmxIrohRegistrationSigner - ) async throws -> CmxIrohRegistrationResponse { - registrationCount += 1 - preparedRegistrations.append(prepared) - let readyIDs = registrationCountWaiters.compactMap { id, waiter in - registrationCount >= waiter.minimum ? id : nil - } - for id in readyIDs { - registrationCountWaiters.removeValue(forKey: id)?.continuation.resume() - } - if registrationCount == 1, let registrationError { - throw registrationError - } - if registrationCount > 1, !subsequentRegistrationErrors.isEmpty { - throw subsequentRegistrationErrors.removeFirst() - } - if registrationCount > 1, let subsequentRegistrationHook { - await subsequentRegistrationHook() - } - if let registrationHook { - registrationHookResult = await registrationHook() - } - let binding = registrationBindings.count > 1 - ? registrationBindings.removeFirst() - : registrationBindings[0] - return CmxIrohRegistrationResponse( - binding: binding, - relay: .unavailable - ) - } - - func discover() throws -> CmxIrohDiscoveryResponse { - if let discoveryError { throw discoveryError } - guard discoveryResponses.count > 1 else { - return discoveryResponses[0] - } - return discoveryResponses.removeFirst() - } - - func issueEndpointAttestation( - bindingID _: String - ) throws -> CmxIrohEndpointAttestationResponse { - throw TestIrohTransportError.unsupported - } - - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) async -> CmxIrohRelayTokenResponse { - relayIssueCount += 1 - if let relayIssueHook { - await relayIssueHook() - } - return CmxIrohRelayTokenResponse( - token: "testrelaytoken", - expiresAt: "2027-07-10T12:00:00.000Z", - refreshAfter: "2027-07-10T11:00:00.000Z", - relayFleet: HostRuntimeFixture.relayURLs - ) - } - - func revoke(bindingID: String) throws { - revokedBindingIDs.append(bindingID) - if let revokeError { throw revokeError } - } - - func observedRegistrationCount() -> Int { registrationCount } - func observedPreparedRegistrations() -> [CmxIrohPreparedRegistration] { - preparedRegistrations - } - func observedRelayIssueCount() -> Int { relayIssueCount } - - func enqueueSubsequentRegistrationError( - _ error: CmxIrohTrustBrokerClientError - ) { - subsequentRegistrationErrors.append(error) - } - - func waitForRegistrationCount(_ minimum: Int) async { - if registrationCount >= minimum { return } - let id = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume() - } else { - registrationCountWaiters[id] = (minimum, continuation) - } - } - } onCancel: { - Task { await self.cancelRegistrationWaiter(id) } - } - } - - func waitForRegistrationCount(_ minimum: Int, timeout: Duration) async -> Bool { - if registrationCount >= minimum { return true } - return await withTaskGroup(of: Bool.self) { group in - group.addTask { - await self.waitForRegistrationCount(minimum) - return !Task.isCancelled - } - group.addTask { - do { - try await ContinuousClock().sleep(for: timeout) - } catch { - return false - } - return false - } - let result = await group.next() ?? false - group.cancelAll() - return result - } - } - - private func cancelRegistrationWaiter(_ id: UUID) { - registrationCountWaiters.removeValue(forKey: id)?.continuation.resume() - } - - func observedRegistrationHookResult() -> Bool? { registrationHookResult } - func observedRevokedBindingIDs() -> [String] { revokedBindingIDs } -} - -actor HostRuntimeBindingRecorder { - private var recordedCount = 0 - - func record() { recordedCount += 1 } - func count() -> Int { recordedCount } -} - -actor HostRuntimeLANRefreshRecorder { - private var recordedCount = 0 - private var waiters: [ - UUID: (minimum: Int, continuation: CheckedContinuation<Void, Never>) - ] = [:] - - func record() { - recordedCount += 1 - let readyIDs = waiters.compactMap { id, waiter in - recordedCount >= waiter.minimum ? id : nil - } - for id in readyIDs { - waiters.removeValue(forKey: id)?.continuation.resume() - } - } - - func waitForRefresh(timeout: Duration) async -> Bool { - await waitForCount(1, timeout: timeout) - } - - func waitForCount(_ count: Int, timeout: Duration) async -> Bool { - if recordedCount >= count { return true } - return await withTaskGroup(of: Bool.self) { group in - group.addTask { - await self.waitForCount(count) - return true - } - group.addTask { - do { - // A bounded test deadline prevents a missing lifecycle signal from hanging CI. - try await ContinuousClock().sleep(for: timeout) - } catch {} - return false - } - let result = await group.next() ?? false - group.cancelAll() - return result - } - } - - func count() -> Int { recordedCount } - - private func waitForCount(_ count: Int) async { - if recordedCount >= count { return } - let id = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume() - } else { - waiters[id] = (count, continuation) - } - } - } onCancel: { - Task { await self.cancelWaiter(id) } - } - } - - private func cancelWaiter(_ id: UUID) { - waiters.removeValue(forKey: id)?.continuation.resume() - } -} - -actor HostRuntimeRegistrationGate { - private var shouldBlock = true - private var opened = false - private var waiter: CheckedContinuation<Void, Never>? - - func waitOnce() async { - guard shouldBlock else { return } - shouldBlock = false - guard !opened else { return } - await withCheckedContinuation { continuation in - waiter = continuation - } - } - - func open() { - opened = true - waiter?.resume() - waiter = nil - } -} - -actor HostRuntimeLANPolicyRecorder { - private var recordedContexts: [CmxIrohHostLANAdvertisementContext] = [] - private var recordedAddresses: [[String]] = [] - private var waiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = [] - - func record( - context: CmxIrohHostLANAdvertisementContext, - directAddresses: [String] - ) { - recordedContexts.append(context) - recordedAddresses.append(directAddresses) - let ready = waiters.filter { recordedContexts.count >= $0.count } - waiters.removeAll { recordedContexts.count >= $0.count } - for waiter in ready { waiter.continuation.resume() } - } - - func contexts() -> [CmxIrohHostLANAdvertisementContext] { recordedContexts } - func addresses() -> [[String]] { recordedAddresses } - - func waitForCount(_ count: Int) async { - if recordedContexts.count >= count { return } - await withCheckedContinuation { continuation in - waiters.append((count, continuation)) - } - } -} - -actor HostRuntimeSignOutOrderingRecorder { - private var recorded: [String] = [] - - func record(endpointClosed: Bool, revocationQueued: Bool) { - recorded.append("\(endpointClosed):\(revocationQueued)") - } - - func values() -> [String] { recorded } -} - -actor HostRuntimeAcceptingEndpoint: CmxIrohEndpoint { - private let peerIdentity: CmxIrohPeerIdentity - private var connections: [any CmxIrohConnection] = [] - private var waiters: [ - UUID: CheckedContinuation<(any CmxIrohConnection)?, Never> - ] = [:] - private let health: AsyncStream<CmxIrohEndpointHealthEvent> - private let healthContinuation: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - private var closed = false - private var closeCallCount = 0 - - init(identity: CmxIrohPeerIdentity) { - peerIdentity = identity - let stream = AsyncStream<CmxIrohEndpointHealthEvent>.makeStream() - health = stream.stream - healthContinuation = stream.continuation - } - - func identity() -> CmxIrohPeerIdentity { peerIdentity } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: peerIdentity, pathHints: []) - } - - func connect( - to _: CmxIrohEndpointAddress, - alpn _: Data - ) async throws -> any CmxIrohConnection { - throw TestIrohTransportError.unsupported - } - - func accept() async throws -> (any CmxIrohConnection)? { - try Task.checkCancellation() - if !connections.isEmpty { return connections.removeFirst() } - guard !closed else { return nil } - let id = UUID() - let connection = await withTaskCancellationHandler { - await withCheckedContinuation { waiters[id] = $0 } - } onCancel: { - Task { await self.cancelAccept(id) } - } - try Task.checkCancellation() - return connection - } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { health } - func isHealthy() -> Bool { true } - - func close() { - closed = true - closeCallCount += 1 - let pending = waiters.values - waiters.removeAll() - for continuation in pending { continuation.resume(returning: nil) } - healthContinuation.finish() - } - - func enqueue(_ connection: any CmxIrohConnection) { - if let id = waiters.keys.first, - let continuation = waiters.removeValue(forKey: id) { - continuation.resume(returning: connection) - } else { - connections.append(connection) - } - } - - func observedCloseCallCount() -> Int { closeCallCount } - - private func cancelAccept(_ id: UUID) { - waiters.removeValue(forKey: id)?.resume(returning: nil) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohIdentityRepositoryTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohIdentityRepositoryTests.swift deleted file mode 100644 index cfea3435..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohIdentityRepositoryTests.swift +++ /dev/null @@ -1,167 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh identity repository") -struct CmxIrohIdentityRepositoryTests { - @Test("identity remains stable inside one install and account scope") - func stableIdentity() async throws { - let harness = IdentityHarness() - let repository = harness.repository() - - let first = try await repository.identity(accountID: "user-a", appInstanceID: "app-a") - let second = try await repository.identity(accountID: "user-a", appInstanceID: "app-a") - - #expect(first == second) - #expect(first.generation == 1) - #expect(harness.secure.deleteAllCount == 1) - } - - @Test("account switches rotate and do not resurrect prior keys") - func accountSwitchRotates() async throws { - let harness = IdentityHarness() - let repository = harness.repository() - - let accountA = try await repository.identity(accountID: "user-a", appInstanceID: "app") - let accountB = try await repository.identity(accountID: "user-b", appInstanceID: "app") - let accountAAgain = try await repository.identity(accountID: "user-a", appInstanceID: "app") - - #expect(accountA.secretKey != accountB.secretKey) - #expect(accountA.secretKey != accountAAgain.secretKey) - #expect(harness.secure.deleteAllCount == 3) - } - - @Test("missing install marker rejects a key that survived uninstall") - func reinstallRotatesSurvivingKey() async throws { - let harness = IdentityHarness() - let repository = harness.repository() - let original = try await repository.identity(accountID: "user", appInstanceID: "app") - - harness.state.removeInstallMarker() - let afterReinstall = try await repository.identity(accountID: "user", appInstanceID: "app") - - #expect(original.secretKey != afterReinstall.secretKey) - #expect(afterReinstall.generation == 1) - #expect(harness.secure.deleteAllCount == 2) - } - - @Test("explicit rotation increments generation without changing scope") - func explicitRotationIncrementsGeneration() async throws { - let harness = IdentityHarness() - let repository = harness.repository() - let original = try await repository.identity(accountID: "user", appInstanceID: "app") - - let rotated = try await repository.rotate(accountID: "user", appInstanceID: "app") - let reloaded = try await repository.identity(accountID: "user", appInstanceID: "app") - - #expect(rotated.secretKey != original.secretKey) - #expect(rotated.generation == 2) - #expect(reloaded == rotated) - } - - @Test("deactivation removes the active key") - func deactivationRemovesKey() async throws { - let harness = IdentityHarness() - let repository = harness.repository() - let original = try await repository.identity(accountID: "user", appInstanceID: "app") - - try await repository.deactivate() - let replacement = try await repository.identity(accountID: "user", appInstanceID: "app") - - #expect(replacement.secretKey != original.secretKey) - #expect(replacement.generation == 1) - } - - @Test("empty account and app scopes are rejected") - func invalidScopes() async throws { - let harness = IdentityHarness() - let repository = harness.repository() - - await #expect(throws: CmxIrohIdentityRepositoryError.invalidScope) { - try await repository.identity(accountID: "", appInstanceID: "app") - } - await #expect(throws: CmxIrohIdentityRepositoryError.invalidScope) { - try await repository.identity(accountID: "user", appInstanceID: "") - } - } -} - -private final class IdentityHarness: @unchecked Sendable { - let secure = TestSecureIdentityStore() - let state = TestInstallStateStore() - private let entropy = TestIdentityEntropy() - - func repository() -> CmxIrohIdentityRepository { - CmxIrohIdentityRepository( - secureStore: secure, - installState: state, - randomBytes: { [entropy] in entropy.nextBytes() }, - marker: { [entropy] in entropy.nextMarker() } - ) - } -} - -private final class TestSecureIdentityStore: CmxIrohSecureIdentityStoring, @unchecked Sendable { - private let lock = NSLock() - private var records: [String: Data] = [:] - private var storedDeleteAllCount = 0 - - var deleteAllCount: Int { - lock.withLock { storedDeleteAllCount } - } - - func read(account: String) -> Data? { - lock.withLock { records[account] } - } - - func write(_ data: Data, account: String) { - lock.withLock { records[account] = data } - } - - func delete(account: String) { - _ = lock.withLock { records.removeValue(forKey: account) } - } - - func deleteAll() { - lock.withLock { - records.removeAll() - storedDeleteAllCount += 1 - } - } -} - -private final class TestInstallStateStore: CmxIrohInstallStateStoring, @unchecked Sendable { - private let lock = NSLock() - private var values: [String: String] = [:] - - func string(forKey key: String) -> String? { - lock.withLock { values[key] } - } - - func set(_ value: String?, forKey key: String) { - lock.withLock { values[key] = value } - } - - func removeInstallMarker() { - _ = lock.withLock { values.removeValue(forKey: "cmux.iroh.identity.install-marker.v1") } - } -} - -private final class TestIdentityEntropy: @unchecked Sendable { - private let lock = NSLock() - private var counter: UInt8 = 0 - - func nextBytes() -> Data { - lock.withLock { - counter &+= 1 - return Data(repeating: counter, count: 32) - } - } - - func nextMarker() -> String { - lock.withLock { - counter &+= 1 - return "marker-\(counter)" - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANDiscoveryTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANDiscoveryTests.swift deleted file mode 100644 index 9d2e0696..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANDiscoveryTests.swift +++ /dev/null @@ -1,331 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohLANDiscoveryTests { - private let date = Date(timeIntervalSince1970: 1_800_000_001) - - @Test - func builderPublishesOnlyRotatingAliasAndExactInterfaceAddresses() throws { - let binding = try makeBinding() - let advertisements = try CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: makeRendezvous(), - binding: binding, - directAddresses: [ - "0.0.0.0:50906", - "192.168.1.10:50907", - "203.0.113.9:50908", - "not-an-address", - ], - interfaces: [ - try interface(4, "192.168.1.10", "255.255.255.0"), - try interface(5, "10.0.0.2", "255.255.255.0"), - ], - at: date - ) - - #expect(advertisements.map(\.interfaceIndex) == [4, 5]) - #expect(advertisements[0].addresses.map(\.value) == [ - "192.168.1.10:50906", - "192.168.1.10:50907", - ]) - #expect(advertisements[1].addresses.map(\.value) == ["10.0.0.2:50906"]) - for advertisement in advertisements { - #expect(advertisement.alias.utf8.count == 32) - #expect(advertisement.hostTarget == "h-\(advertisement.alias).local.") - let payload = String(decoding: advertisement.txtRecord, as: UTF8.self) - #expect(!payload.contains(binding.bindingID)) - #expect(!payload.contains(binding.endpointID.endpointID)) - #expect(!payload.contains(binding.deviceID)) - #expect(!payload.contains(binding.tag)) - #expect(!advertisement.hostTarget.contains("Mac")) - } - } - - @Test - func builderRotatesServiceAndHostWithoutChangingReachability() throws { - let builder = CmxIrohLANAdvertisementBuilder() - let arguments = ( - rendezvous: makeRendezvous(), - binding: try makeBinding(), - directAddresses: ["192.168.1.10:50906"], - interfaces: [try interface(4, "192.168.1.10", "255.255.255.0")] - ) - let first = try #require(builder.advertisements( - rendezvous: arguments.rendezvous, - binding: arguments.binding, - directAddresses: arguments.directAddresses, - interfaces: arguments.interfaces, - at: date - ).first) - let next = try #require(builder.advertisements( - rendezvous: arguments.rendezvous, - binding: arguments.binding, - directAddresses: arguments.directAddresses, - interfaces: arguments.interfaces, - at: date.addingTimeInterval(300) - ).first) - - #expect(first.alias != next.alias) - #expect(first.hostTarget != next.hostTarget) - #expect(first.addresses == next.addresses) - } - - @Test - func txtCodecRejectsNonCanonicalAndDuplicateAddresses() throws { - let valid = try CmxIrohLANTXTRecord( - epoch: 6_000_000, - addresses: [try CmxIrohLANSocketAddress("192.168.1.10:50906")] - ) - #expect(try CmxIrohLANTXTRecord(encoded: valid.encoded()) == valid) - - let duplicate = try CmxIrohLANSocketAddress("192.168.1.10:50906") - #expect(throws: CmxIrohLANDiscoveryError.invalidTXTRecord) { - _ = try CmxIrohLANTXTRecord(epoch: 1, addresses: [duplicate, duplicate]) - } - #expect(throws: CmxIrohLANDiscoveryError.invalidSocketAddress) { - _ = try CmxIrohLANSocketAddress("192.168.001.10:50906") - } - #expect(throws: CmxIrohLANDiscoveryError.invalidSocketAddress) { - _ = try CmxIrohLANSocketAddress("127.0.0.1:50906") - } - #expect(throws: CmxIrohLANDiscoveryError.invalidSocketAddress) { - _ = try CmxIrohLANSocketAddress("[fe80::1]:50906") - } - } - - @Test - func resolverMapsOnlyOneAuthenticatedExpectedMacAndBuildsFallbackHints() throws { - let rendezvous = makeRendezvous() - let binding = try makeBinding() - let advertisement = try #require(CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: rendezvous, - binding: binding, - directAddresses: ["192.168.1.10:50906"], - interfaces: [try interface(4, "192.168.1.10", "255.255.255.0")], - at: date - ).first) - let path = CmxIrohNetworkPathSnapshot(generation: 7, activeNetworkProfiles: []) - let resolved = try CmxIrohLANDiscoveryResolver().resolve( - service(advertisement), - rendezvous: rendezvous, - authenticatedBindings: [binding], - expectedMacDeviceID: binding.deviceID, - expectedEndpointID: binding.endpointID, - networkPathSnapshot: path, - interfaces: [try interface(4, "192.168.1.22", "255.255.255.0")], - at: date - ) - - #expect(resolved.binding == binding) - #expect(resolved.pathGeneration == 7) - #expect(resolved.pathHints.count == 1) - #expect(resolved.pathHints[0].source == .lan) - #expect(resolved.pathHints[0].privacyScope == .localNetwork) - #expect(resolved.pathHints[0].use == .fallbackOnly) - #expect(resolved.pathHints[0].networkProfile == resolved.networkProfile) - let active = CmxIrohNetworkPathSnapshot( - generation: path.generation, - activeNetworkProfiles: [resolved.networkProfile] - ) - #expect(throws: Never.self) { - _ = try CmxIrohPrivateFallbackAuthorization( - networkPathSnapshot: active, - pathHints: resolved.pathHints, - admittedAt: date - ) - } - } - - @Test - func resolverRejectsUnknownAliasWrongSubnetAndDescriptiveHostname() throws { - let rendezvous = makeRendezvous() - let binding = try makeBinding() - let advertisement = try #require(CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: rendezvous, - binding: binding, - directAddresses: ["192.168.1.10:50906"], - interfaces: [try interface(4, "192.168.1.10", "255.255.255.0")], - at: date - ).first) - let resolver = CmxIrohLANDiscoveryResolver() - let path = CmxIrohNetworkPathSnapshot(generation: 1, activeNetworkProfiles: []) - - #expect(throws: CmxIrohLANDiscoveryError.invalidInterface) { - _ = try resolver.resolve( - service(advertisement), - rendezvous: rendezvous, - authenticatedBindings: [binding], - expectedMacDeviceID: binding.deviceID, - networkPathSnapshot: path, - interfaces: [try interface(4, "10.0.0.2", "255.255.255.0")], - at: date - ) - } - let leaking = CmxIrohBonjourResolvedService( - serviceName: advertisement.alias, - hostTarget: "Lawrences-Mac.local.", - interfaceIndex: advertisement.interfaceIndex, - port: advertisement.port, - txtRecord: advertisement.txtRecord - ) - #expect(throws: CmxIrohLANDiscoveryError.invalidAdvertisement) { - _ = try resolver.resolve( - leaking, - rendezvous: rendezvous, - authenticatedBindings: [binding], - expectedMacDeviceID: binding.deviceID, - networkPathSnapshot: path, - interfaces: [try interface(4, "192.168.1.22", "255.255.255.0")], - at: date - ) - } - #expect(throws: CmxIrohLANDiscoveryError.ambiguousBinding) { - _ = try resolver.resolve( - service(advertisement), - rendezvous: rendezvous, - authenticatedBindings: [binding], - expectedMacDeviceID: "123e4567-e89b-42d3-a456-426614174099", - networkPathSnapshot: path, - interfaces: [try interface(4, "192.168.1.22", "255.255.255.0")], - at: date - ) - } - } - - @Test - func resolverRejectsAddressOwnedByOverlappingInterfaces() throws { - let rendezvous = makeRendezvous() - let binding = try makeBinding() - let advertisement = try #require(CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: rendezvous, - binding: binding, - directAddresses: ["192.168.1.10:50906"], - interfaces: [try interface(4, "192.168.1.10", "255.255.255.0")], - at: date - ).first) - - #expect(throws: CmxIrohLANDiscoveryError.invalidInterface) { - _ = try CmxIrohLANDiscoveryResolver().resolve( - service(advertisement), - rendezvous: rendezvous, - authenticatedBindings: [binding], - expectedMacDeviceID: binding.deviceID, - expectedEndpointID: binding.endpointID, - networkPathSnapshot: .init( - generation: 1, - activeNetworkProfiles: [] - ), - interfaces: [ - try interface(4, "192.168.1.22", "255.255.255.0"), - try interface(5, "192.168.1.33", "255.255.255.0"), - ], - at: date - ) - } - } - - @Test - func resolverRejectsReplayedEpochEvenWithKnownAlias() throws { - let rendezvous = makeRendezvous() - let binding = try makeBinding() - let oldDate = date.addingTimeInterval(-900) - let advertisement = try #require(CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: rendezvous, - binding: binding, - directAddresses: ["192.168.1.10:50906"], - interfaces: [try interface(4, "192.168.1.10", "255.255.255.0")], - at: oldDate - ).first) - - #expect(throws: (any Error).self) { - _ = try CmxIrohLANDiscoveryResolver().resolve( - service(advertisement), - rendezvous: rendezvous, - authenticatedBindings: [binding], - expectedMacDeviceID: binding.deviceID, - networkPathSnapshot: .init(generation: 1, activeNetworkProfiles: []), - interfaces: [try interface(4, "192.168.1.22", "255.255.255.0")], - at: date - ) - } - } - - @Test - func profileChangesWithPathInterfaceAndBrokerGeneration() throws { - let first = try CmxIrohLANNetworkProfileGenerator(rendezvous: makeRendezvous(generation: 1)) - let next = try CmxIrohLANNetworkProfileGenerator(rendezvous: makeRendezvous(generation: 2)) - let values = try Set([ - first.profile(interfaceIndex: 4, pathGeneration: 1), - first.profile(interfaceIndex: 4, pathGeneration: 2), - first.profile(interfaceIndex: 5, pathGeneration: 1), - next.profile(interfaceIndex: 4, pathGeneration: 1), - ]) - - #expect(values.count == 4) - #expect(values.allSatisfy { $0.source == .lan && $0.profileID.utf8.count == 64 }) - } - - @Test - func interfaceFilterAllowsPhysicalAndConfiguredLANsButRejectsVirtualLinks() { - for name in ["en0", "en12", "vlan0", "vlan42", "bond0"] { - #expect(CmxIrohSystemLANInterfaceSnapshotProvider.isEligibleInterfaceName(name)) - } - for name in [ - "lo0", "awdl0", "llw0", "ap1", "anpi0", "utun3", "ipsec0", - "pdp_ip0", "bridge0", "gif0", "stf0", "vmenet0", "vmnet1", - "tap0", "tun0", "docker0", "veth0", "en", "enx", - ] { - #expect(!CmxIrohSystemLANInterfaceSnapshotProvider.isEligibleInterfaceName(name)) - } - } - - private func service(_ advertisement: CmxIrohLANAdvertisement) -> CmxIrohBonjourResolvedService { - CmxIrohBonjourResolvedService( - serviceName: advertisement.alias, - hostTarget: advertisement.hostTarget, - interfaceIndex: advertisement.interfaceIndex, - port: advertisement.port, - txtRecord: advertisement.txtRecord - ) - } - - private func interface( - _ index: UInt32, - _ address: String, - _ mask: String - ) throws -> CmxIrohLANInterfaceAddress { - try CmxIrohLANInterfaceAddress( - interfaceIndex: index, - ipAddress: address, - netmask: mask - ) - } - - private func makeRendezvous(generation: Int = 3) -> CmxIrohLANRendezvous { - let key = Data(repeating: 7, count: 32) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - let data = try! JSONSerialization.data(withJSONObject: [ - "generation": generation, - "key": key, - ]) - return try! JSONDecoder().decode(CmxIrohLANRendezvous.self, from: data) - } - - private func makeBinding() throws -> CmxIrohBrokerBindingMetadata { - try CmxIrohBrokerBindingMetadata( - bindingID: "123e4567-e89b-42d3-a456-426614174010", - deviceID: "123e4567-e89b-42d3-a456-426614174011", - appInstanceID: "123e4567-e89b-42d3-a456-426614174012", - tag: "cmux-ios-v0", - platform: .mac, - endpointID: CmxIrohPeerIdentity(endpointID: String(repeating: "a", count: 64)), - identityGeneration: 4 - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANHostPublisherTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANHostPublisherTests.swift deleted file mode 100644 index 3e740ed8..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANHostPublisherTests.swift +++ /dev/null @@ -1,158 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohLANHostPublisherTests { - @Test - func verifiedRendezvousReplacementRemovesOldAlias() async throws { - let service = RecordingBonjourPublisher() - let binding = try hostBinding() - let host = CmxIrohLANHostPublisher( - publisher: service, - interfaces: TestHostLANInterfaces(values: [try hostInterface()]), - clock: FixedHostLANClock() - ) - - await host.activate( - rendezvous: try rendezvous(generation: 1), - binding: binding, - directAddresses: { ["192.168.1.10:50906"] } - ) - await host.activate( - rendezvous: try rendezvous(generation: 2), - binding: binding, - directAddresses: { ["192.168.1.10:50906"] } - ) - - let replacements = await service.replacements() - #expect(replacements.count == 2) - let first = try #require(replacements[0].first) - let second = try #require(replacements[1].first) - #expect(first.alias != second.alias) - #expect(!replacements[1].contains(where: { $0.alias == first.alias })) - #expect(second.hostTarget == "h-\(second.alias).local.") - await host.stop() - } - - @Test - func policyDenialDisablesOnlyBonjourPublisher() async throws { - let service = RecordingBonjourPublisher(error: .policyDenied) - let host = CmxIrohLANHostPublisher( - publisher: service, - interfaces: TestHostLANInterfaces(values: [try hostInterface()]), - clock: FixedHostLANClock() - ) - - await host.activate( - rendezvous: try rendezvous(generation: 1), - binding: try hostBinding(), - directAddresses: { ["192.168.1.10:50906"] } - ) - - #expect(await host.snapshot() == .policyDenied) - #expect(await service.replacements().isEmpty) - await host.stop() - } - - @Test - func activeListenerCanRetryAfterPermissionMayHaveChanged() async throws { - let service = RecordingBonjourPublisher(error: .policyDenied) - let host = CmxIrohLANHostPublisher( - publisher: service, - interfaces: TestHostLANInterfaces(values: [try hostInterface()]), - clock: FixedHostLANClock() - ) - await host.activate( - rendezvous: try rendezvous(generation: 1), - binding: try hostBinding(), - directAddresses: { ["192.168.1.10:50906"] } - ) - #expect(await host.snapshot() == .policyDenied) - - await service.allowPublishing() - await host.permissionMayHaveChanged() - - #expect(await host.snapshot() == .active) - #expect(await service.replacements().count == 1) - await host.stop() - await host.permissionMayHaveChanged() - #expect(await service.replacements().count == 1) - } - - private func hostInterface() throws -> CmxIrohLANInterfaceAddress { - try CmxIrohLANInterfaceAddress( - interfaceIndex: 4, - ipAddress: "192.168.1.10", - netmask: "255.255.255.0" - ) - } - - private func hostBinding() throws -> CmxIrohBrokerBindingMetadata { - try CmxIrohBrokerBindingMetadata( - bindingID: "123e4567-e89b-42d3-a456-426614174010", - deviceID: "123e4567-e89b-42d3-a456-426614174011", - appInstanceID: "123e4567-e89b-42d3-a456-426614174012", - tag: "test", - platform: .mac, - endpointID: CmxIrohPeerIdentity(endpointID: String(repeating: "a", count: 64)), - identityGeneration: 1 - ) - } - - private func rendezvous(generation: Int) throws -> CmxIrohLANRendezvous { - let key = Data(repeating: 7, count: 32) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - return try JSONDecoder().decode( - CmxIrohLANRendezvous.self, - from: JSONSerialization.data(withJSONObject: [ - "generation": generation, - "key": key, - ]) - ) - } -} - -private struct TestHostLANInterfaces: CmxIrohLANInterfaceSnapshotProviding { - let values: [CmxIrohLANInterfaceAddress] - func interfaceAddresses() throws -> [CmxIrohLANInterfaceAddress] { values } -} - -private struct FixedHostLANClock: CmxIrohLANClock { - func now() -> Date { Date(timeIntervalSince1970: 1_800_000_001) } - func sleep(for _: TimeInterval) async throws { - try await ContinuousClock().sleep(for: .seconds(600)) - } -} - -private actor RecordingBonjourPublisher: CmxIrohBonjourPublishing { - private var error: CmxIrohLANDiscoveryError? - private var recorded: [[CmxIrohLANAdvertisement]] = [] - private var continuations: [AsyncStream<CmxIrohBonjourPublisherEvent>.Continuation] = [] - - init(error: CmxIrohLANDiscoveryError? = nil) { - self.error = error - } - - func events() -> AsyncStream<CmxIrohBonjourPublisherEvent> { - AsyncStream { continuations.append($0) } - } - - func replace(with advertisements: [CmxIrohLANAdvertisement]) throws { - if let error { throw error } - recorded.append(advertisements) - } - - func stop() { - for continuation in continuations { continuation.finish() } - continuations.removeAll() - } - - func allowPublishing() { error = nil } - - func replacements() -> [[CmxIrohLANAdvertisement]] { recorded } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANPeerDiscoveryTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANPeerDiscoveryTests.swift deleted file mode 100644 index d4e67e1a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANPeerDiscoveryTests.swift +++ /dev/null @@ -1,482 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohLANPeerDiscoveryTests { - @Test - func browsingIsLazyAndResolvedProfileIsGenerationScoped() async throws { - let fixture = try Fixture() - #expect(await fixture.factory.callCount() == 0) - - let discoveryTask = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 5 - ) - } - await fixture.browser.waitUntilStarted() - #expect(await fixture.factory.callCount() == 1) - await fixture.browser.emit(.resolved(fixture.serviceID, fixture.service)) - - guard case let .found(peers) = await discoveryTask.value else { - Issue.record("Expected an authenticated LAN result") - return - } - let peer = try #require(peers.first) - #expect(peer.binding == fixture.binding) - #expect(peer.pathGeneration == 1) - #expect(await fixture.path.snapshot().activeNetworkProfiles == [peer.networkProfile]) - } - - @Test - func developmentBindingQuotaStillAllowsPinnedLANDiscovery() async throws { - let fixture = try Fixture() - let path = fixture.path - let discovery = CmxIrohLANPeerDiscovery( - browserFactory: { - PreloadedLANBrowser( - event: .resolved(fixture.serviceID, fixture.service) - ) - }, - interfaces: TestLANInterfaces(values: [ - try CmxIrohLANInterfaceAddress( - interfaceIndex: 4, - ipAddress: "192.168.1.22", - netmask: "255.255.255.0" - ), - ]), - clock: TestLANClock(now: fixture.date), - networkPath: { await path.snapshot() }, - authorizeProfile: { profile, generation, interfaceIndex in - await path.authorize( - profile: profile, - generation: generation, - interfaceIndex: interfaceIndex - ) - }, - revokeProfile: { profile, generation in - await path.revoke(profile: profile, generation: generation) - } - ) - let unrelated = try (1 ... 32).map { index in - try CmxIrohBrokerBindingMetadata( - bindingID: String( - format: "323e4567-e89b-42d3-a456-%012d", - index - ), - deviceID: String( - format: "423e4567-e89b-42d3-a456-%012d", - index - ), - appInstanceID: String( - format: "523e4567-e89b-42d3-a456-%012d", - index - ), - tag: "test-\(index)", - platform: .mac, - endpointID: CmxIrohPeerIdentity( - endpointID: String(format: "%064llx", UInt64(index + 1)) - ), - identityGeneration: 1 - ) - } - let outcome = await discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding] + unrelated, - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 0.2 - ) - - guard case let .found(peers) = outcome else { - Issue.record("Expected pinned LAN discovery within development quota") - return - } - #expect(peers.map(\.binding) == [fixture.binding]) - } - - @Test - func removalRevokesAuthorizationAndOldHintsCannotSurvive() async throws { - let fixture = try Fixture() - let discoveryTask = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 5 - ) - } - await fixture.browser.waitUntilStarted() - await fixture.browser.emit(.resolved(fixture.serviceID, fixture.service)) - guard case .found = await discoveryTask.value else { - Issue.record("Expected initial result") - return - } - - await fixture.browser.emit(.removed(fixture.serviceID)) - await fixture.path.waitForRevocation() - - #expect(await fixture.path.snapshot().activeNetworkProfiles.isEmpty) - #expect(await fixture.path.revocationCount() == 1) - } - - @Test - func pathChangeRevokesProfilesStopsBrowseAndRequiresNewGeneration() async throws { - let fixture = try Fixture() - let discoveryTask = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 5 - ) - } - await fixture.browser.waitUntilStarted() - await fixture.browser.emit(.resolved(fixture.serviceID, fixture.service)) - guard case let .found(peers) = await discoveryTask.value, - let previous = peers.first else { - Issue.record("Expected initial result") - return - } - - await fixture.path.advanceGeneration() - await fixture.discovery.pathDidChange() - - let snapshot = await fixture.path.snapshot() - #expect(snapshot.generation == 2) - #expect(snapshot.activeNetworkProfiles.isEmpty) - #expect(!snapshot.activeNetworkProfiles.contains(previous.networkProfile)) - #expect(await fixture.browser.wasStopped()) - } - - @Test - func policyDeniedIsDistinctAndDoesNotThrowOrAuthorize() async throws { - let fixture = try Fixture() - let discoveryTask = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 5 - ) - } - await fixture.browser.waitUntilStarted() - await fixture.browser.emit(.policyDenied) - - #expect(await discoveryTask.value == .policyDenied) - #expect(await fixture.path.snapshot().activeNetworkProfiles.isEmpty) - #expect(await fixture.browser.wasStopped()) - } - - @Test - func foregroundPermissionResetIsLazyAndNextExplicitDiscoveryCanRetry() async throws { - let fixture = try Fixture() - let deniedTask = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 5 - ) - } - await fixture.browser.waitUntilStarted() - await fixture.browser.emit(.policyDenied) - #expect(await deniedTask.value == .policyDenied) - #expect(await fixture.factory.callCount() == 1) - - await fixture.discovery.permissionMayHaveChanged() - - #expect(await fixture.factory.callCount() == 1) - let retryTask = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 5 - ) - } - await fixture.browser.waitUntilStarted() - await fixture.browser.emit(.resolved(fixture.serviceID, fixture.service)) - - guard case .found = await retryTask.value else { - Issue.record("Expected explicit reconnect to retry Bonjour") - return - } - #expect(await fixture.factory.callCount() == 2) - } - - @Test - func unknownOrUnpairedAliasCannotCreateAProfile() async throws { - let fixture = try Fixture() - let otherBinding = try fixture.makeBinding(endpointByte: "b") - let otherAdvertisement = try #require(CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: fixture.rendezvous, - binding: otherBinding, - directAddresses: ["192.168.1.10:50906"], - interfaces: [fixture.hostInterface], - at: fixture.date - ).first) - let otherID = CmxIrohBonjourServiceID( - serviceName: otherAdvertisement.alias, - interfaceIndex: otherAdvertisement.interfaceIndex - ) - let task = Task { - await fixture.discovery.discover( - rendezvous: fixture.rendezvous, - authenticatedBindings: [fixture.binding], - expectedMacDeviceID: fixture.binding.deviceID, - expectedEndpointID: fixture.binding.endpointID, - timeout: 0.05 - ) - } - await fixture.browser.waitUntilStarted() - await fixture.browser.emit(.resolved( - otherID, - CmxIrohBonjourResolvedService( - serviceName: otherAdvertisement.alias, - hostTarget: otherAdvertisement.hostTarget, - interfaceIndex: otherAdvertisement.interfaceIndex, - port: otherAdvertisement.port, - txtRecord: otherAdvertisement.txtRecord - ) - )) - - #expect(await task.value == .notFound) - #expect(await fixture.path.snapshot().activeNetworkProfiles.isEmpty) - } -} - -private struct PreloadedLANBrowser: CmxIrohBonjourBrowsing { - let event: CmxIrohBonjourBrowserEvent - - func events() async -> AsyncStream<CmxIrohBonjourBrowserEvent> { - AsyncStream { continuation in - continuation.yield(event) - continuation.finish() - } - } - - func stop() async {} -} - -private struct TestLANInterfaces: CmxIrohLANInterfaceSnapshotProviding { - let values: [CmxIrohLANInterfaceAddress] - func interfaceAddresses() throws -> [CmxIrohLANInterfaceAddress] { values } -} - -private actor TestLANBrowser: CmxIrohBonjourBrowsing { - private var continuation: AsyncStream<CmxIrohBonjourBrowserEvent>.Continuation? - private var startWaiters: [CheckedContinuation<Void, Never>] = [] - private var stopped = false - - func events() -> AsyncStream<CmxIrohBonjourBrowserEvent> { - AsyncStream { continuation in - self.continuation = continuation - let waiters = startWaiters - startWaiters.removeAll() - for waiter in waiters { waiter.resume() } - } - } - - func stop() { - stopped = true - continuation?.finish() - continuation = nil - } - - func waitUntilStarted() async { - if continuation != nil { return } - await withCheckedContinuation { startWaiters.append($0) } - } - - func emit(_ event: CmxIrohBonjourBrowserEvent) { - continuation?.yield(event) - } - - func wasStopped() -> Bool { stopped } -} - -private actor TestLANBrowserFactoryRecorder { - private let browser: TestLANBrowser - private var calls = 0 - - init(browser: TestLANBrowser) { - self.browser = browser - } - - nonisolated func make() -> any CmxIrohBonjourBrowsing { - Task { await recordCall() } - return browser - } - - private func recordCall() { calls += 1 } - func callCount() -> Int { calls } -} - -private actor TestLANPathState { - private var generation: UInt64 = 1 - private var profiles: Set<CmxIrohNetworkProfileKey> = [] - private var revocations = 0 - private var revocationWaiters: [CheckedContinuation<Void, Never>] = [] - - func snapshot() -> CmxIrohNetworkPathSnapshot { - CmxIrohNetworkPathSnapshot( - generation: generation, - activeNetworkProfiles: profiles - ) - } - - func authorize( - profile: CmxIrohNetworkProfileKey, - generation expectedGeneration: UInt64, - interfaceIndex: UInt32 - ) -> Bool { - guard expectedGeneration == generation, interfaceIndex == 4 else { return false } - profiles.insert(profile) - return true - } - - func revoke( - profile: CmxIrohNetworkProfileKey, - generation expectedGeneration: UInt64 - ) { - if expectedGeneration <= generation { profiles.remove(profile) } - revocations += 1 - let waiters = revocationWaiters - revocationWaiters.removeAll() - for waiter in waiters { waiter.resume() } - } - - func advanceGeneration() { - generation &+= 1 - profiles.removeAll() - } - - func waitForRevocation() async { - if revocations > 0 { return } - await withCheckedContinuation { revocationWaiters.append($0) } - } - - func revocationCount() -> Int { revocations } -} - -private struct Fixture { - let date = Date(timeIntervalSince1970: 1_800_000_001) - let rendezvous: CmxIrohLANRendezvous - let binding: CmxIrohBrokerBindingMetadata - let hostInterface: CmxIrohLANInterfaceAddress - let serviceID: CmxIrohBonjourServiceID - let service: CmxIrohBonjourResolvedService - let browser: TestLANBrowser - let factory: TestLANBrowserFactoryRecorder - let path: TestLANPathState - let discovery: CmxIrohLANPeerDiscovery - - init() throws { - let key = Data(repeating: 7, count: 32) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - let data = try JSONSerialization.data(withJSONObject: [ - "generation": 3, - "key": key, - ]) - rendezvous = try JSONDecoder().decode(CmxIrohLANRendezvous.self, from: data) - binding = try Self.binding(endpointByte: "a") - hostInterface = try CmxIrohLANInterfaceAddress( - interfaceIndex: 4, - ipAddress: "192.168.1.10", - netmask: "255.255.255.0" - ) - let advertisement = try #require(CmxIrohLANAdvertisementBuilder().advertisements( - rendezvous: rendezvous, - binding: binding, - directAddresses: ["192.168.1.10:50906"], - interfaces: [hostInterface], - at: date - ).first) - serviceID = CmxIrohBonjourServiceID( - serviceName: advertisement.alias, - interfaceIndex: advertisement.interfaceIndex - ) - service = CmxIrohBonjourResolvedService( - serviceName: advertisement.alias, - hostTarget: advertisement.hostTarget, - interfaceIndex: advertisement.interfaceIndex, - port: advertisement.port, - txtRecord: advertisement.txtRecord - ) - browser = TestLANBrowser() - factory = TestLANBrowserFactoryRecorder(browser: browser) - path = TestLANPathState() - let clientInterface = try CmxIrohLANInterfaceAddress( - interfaceIndex: 4, - ipAddress: "192.168.1.22", - netmask: "255.255.255.0" - ) - let factory = factory - let path = path - discovery = CmxIrohLANPeerDiscovery( - browserFactory: { factory.make() }, - interfaces: TestLANInterfaces(values: [clientInterface]), - clock: TestLANClock(now: date), - networkPath: { await path.snapshot() }, - authorizeProfile: { profile, generation, interfaceIndex in - await path.authorize( - profile: profile, - generation: generation, - interfaceIndex: interfaceIndex - ) - }, - revokeProfile: { profile, generation in - await path.revoke(profile: profile, generation: generation) - } - ) - } - - func makeBinding(endpointByte: Character) throws -> CmxIrohBrokerBindingMetadata { - try Self.binding(endpointByte: endpointByte) - } - - private static func binding(endpointByte: Character) throws -> CmxIrohBrokerBindingMetadata { - try CmxIrohBrokerBindingMetadata( - bindingID: endpointByte == "a" - ? "123e4567-e89b-42d3-a456-426614174010" - : "123e4567-e89b-42d3-a456-426614174020", - deviceID: endpointByte == "a" - ? "123e4567-e89b-42d3-a456-426614174011" - : "123e4567-e89b-42d3-a456-426614174021", - appInstanceID: endpointByte == "a" - ? "123e4567-e89b-42d3-a456-426614174012" - : "123e4567-e89b-42d3-a456-426614174022", - tag: "test", - platform: .mac, - endpointID: CmxIrohPeerIdentity( - endpointID: String(repeating: endpointByte, count: 64) - ), - identityGeneration: 1 - ) - } -} - -private struct TestLANClock: CmxIrohLANClock { - let value: Date - - init(now: Date) { value = now } - func now() -> Date { value } - func sleep(for interval: TimeInterval) async throws { - let milliseconds = Int64(interval * 1_000) - try await ContinuousClock().sleep(for: .milliseconds(milliseconds)) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANRendezvousAliasGeneratorTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANRendezvousAliasGeneratorTests.swift deleted file mode 100644 index e118ec95..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLANRendezvousAliasGeneratorTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohLANRendezvousAliasGeneratorTests { - @Test - func aliasIsStableInsideEpochAndRotatesWithoutExposingTuple() throws { - let generator = try makeGenerator(keyByte: 7, generation: 3) - let binding = try makeBinding() - let start = Date(timeIntervalSince1970: 1_800_000_001) - - let first = try generator.alias(for: binding, at: start) - let sameEpoch = try generator.alias( - for: binding, - at: start.addingTimeInterval(120) - ) - let nextEpoch = try generator.alias( - for: binding, - at: start.addingTimeInterval(300) - ) - - #expect(first == sameEpoch) - #expect(first != nextEpoch) - #expect(first.utf8.count == 32) - #expect(!first.contains(binding.bindingID)) - #expect(!first.contains(binding.endpointID.endpointID)) - } - - @Test - func aliasBindsSecretGenerationAndEveryPeerIdentityField() throws { - let date = Date(timeIntervalSince1970: 1_800_000_001) - let binding = try makeBinding() - let baseline = try makeGenerator(keyByte: 7, generation: 3) - .alias(for: binding, at: date) - let changedKey = try makeGenerator(keyByte: 8, generation: 3) - .alias(for: binding, at: date) - let changedGeneration = try makeGenerator(keyByte: 7, generation: 4) - .alias(for: binding, at: date) - let changedIdentity = try makeGenerator(keyByte: 7, generation: 3) - .alias( - for: makeBinding(endpointByte: "b"), - at: date - ) - - #expect(Set([baseline, changedKey, changedGeneration, changedIdentity]).count == 4) - } - - @Test - func resolverAcceptsClockBoundaryButRejectsUnknownAndAmbiguousInput() throws { - let generator = try makeGenerator(keyByte: 7, generation: 3) - let binding = try makeBinding() - let date = Date(timeIntervalSince1970: 1_800_000_001) - let previous = try generator.alias( - for: binding, - at: date.addingTimeInterval(-300) - ) - - #expect( - try generator.binding( - matching: previous, - among: [binding], - at: date - ) == binding - ) - #expect( - try generator.binding( - matching: String(repeating: "0", count: 32), - among: [binding], - at: date - ) == nil - ) - #expect( - try generator.binding( - matching: "not-an-alias", - among: [binding], - at: date - ) == nil - ) - } - - @Test - func nonMacBindingsCannotBecomeAdvertisedServices() throws { - let generator = try makeGenerator(keyByte: 7, generation: 3) - let binding = try makeBinding(platform: .ios) - - #expect(throws: CmxIrohLANRendezvousAliasError.unsupportedPlatform) { - try generator.alias( - for: binding, - at: Date(timeIntervalSince1970: 1_800_000_001) - ) - } - } - - private func makeGenerator( - keyByte: UInt8, - generation: Int - ) throws -> CmxIrohLANRendezvousAliasGenerator { - let key = Data(repeating: keyByte, count: 32) - .base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - let data = try JSONSerialization.data(withJSONObject: [ - "generation": generation, - "key": key, - ]) - return try CmxIrohLANRendezvousAliasGenerator( - rendezvous: JSONDecoder().decode(CmxIrohLANRendezvous.self, from: data) - ) - } - - private func makeBinding( - endpointByte: Character = "a", - platform: CmxIrohPlatform = .mac - ) throws -> CmxIrohBrokerBindingMetadata { - try CmxIrohBrokerBindingMetadata( - bindingID: "123e4567-e89b-42d3-a456-426614174010", - deviceID: "123e4567-e89b-42d3-a456-426614174011", - appInstanceID: "123e4567-e89b-42d3-a456-426614174012", - tag: "cmux-ios-v0", - platform: platform, - endpointID: CmxIrohPeerIdentity( - endpointID: String(repeating: endpointByte, count: 64) - ), - identityGeneration: 4 - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift deleted file mode 100644 index 26102c2a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohLibEndpointTests.swift +++ /dev/null @@ -1,448 +0,0 @@ -import CMUXMobileCore -import Darwin -import Foundation -import IrohLib -import Testing -@testable import CmuxIrohTransport - -@Suite(.serialized) -struct CmxIrohLibEndpointTests { - @Test - func verificationModeSelectsTheExpectedIrohRelayMode() throws { - let cases: [(CmxIrohTransportVerificationMode, Bool)] = [ - (.automatic, false), - (.relayOnly, false), - (.directOnly, true), - ] - for (mode, expectsDisabledRelayMode) in cases { - let options = CmxIrohLibEndpointFactory.endpointOptions( - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ), - socketAddress: nil, - relayMap: RelayMap.empty(), - transportVerificationMode: mode - ) - - #expect( - (options.relayMode?.description == "disabled") - == expectsDisabledRelayMode - ) - } - } - - @Test - func relayOnlyVerificationModeDisablesPostAdmissionNATTraversal() { - #expect(CmxIrohTransportVerificationMode.automatic.allowsNATTraversalAfterAdmission) - #expect(!CmxIrohTransportVerificationMode.relayOnly.allowsNATTraversalAfterAdmission) - #expect(CmxIrohTransportVerificationMode.directOnly.allowsNATTraversalAfterAdmission) - } - - @Test - func cmuxEndpointStartsWithNoStreamCreditOrPreAdmissionNatTraversal() throws { - let options = CmxIrohLibEndpointFactory.endpointOptions( - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ), - socketAddress: nil, - relayMap: RelayMap.empty() - ) - - #expect(options.portMappingEnabled == false) - #expect(options.deferNatTraversalUntilAuthorized == true) - #expect(options.initialMaxConcurrentBiStreams == 0) - #expect(options.initialMaxConcurrentUniStreams == 0) - } - - @Test - func minimalPresetPreservesIdentityWithoutPublicN0Relays() async throws { - let endpoint = try await makeEndpoint(managedRelayURLs: []) - let expectedID = - "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" - - #expect(await endpoint.identity().endpointID == expectedID) - let address = await endpoint.address() - #expect(address.identity.endpointID == expectedID) - #expect(address.pathHints.allSatisfy { $0.kind != .relayURL }) - let localDirectAddresses = await endpoint.localDirectAddresses() - #expect(localDirectAddresses.allSatisfy { !$0.hasPrefix("https://") }) - #expect(address.pathHints.allSatisfy { hint in - hint.privacyScope == .publicInternet - && (!localDirectAddresses.contains(hint.value) - || hint.publicDisclosure(at: Date()) != nil) - }) - - let events = await endpoint.healthEvents() - let collected = Task { () -> [CmxIrohEndpointHealthEvent] in - var values: [CmxIrohEndpointHealthEvent] = [] - for await event in events { values.append(event) } - return values - } - await endpoint.close() - let observed = await collected.value - #expect(!observed.contains(.closedUnexpectedly)) - } - - @Test - func monitoringDoesNotSynthesizeNetworkChangeEvents() async throws { - let endpoint = try await makeEndpoint(managedRelayURLs: []) - let events = await endpoint.healthEvents() - let collected = Task { () -> Int in - var networkChanges = 0 - for await event in events { - if event == .networkChanged { - networkChanges += 1 - } - if networkChanges == 32 { - break - } - } - return networkChanges - } - - for _ in 0 ..< 100 { - await Task.yield() - } - await endpoint.close() - - #expect(await collected.value < 32) - } - - @Test - func directOnlyReplaysAddressObservedBeforeHealthSubscription() async throws { - let (endpoint, observedAddress) = try await makeUnmonitoredEndpoint( - transportVerificationMode: .directOnly - ) - - await endpoint.recordAddressSnapshot(observedAddress) - - let events = await endpoint.healthEvents() - #expect( - await firstHealthEvent(in: events, timeout: .seconds(1)) == .networkChanged - ) - await endpoint.close() - } - - @Test - func onlineStateReplaysToLateHealthObservers() async throws { - let endpoint = try await makeEndpoint(managedRelayURLs: []) - let concrete = try #require(endpoint as? CmxIrohLibEndpoint) - await concrete.recordHealthEvent(.online) - - let initialEvents = await endpoint.healthEvents() - #expect( - await firstHealthEvent(in: initialEvents, timeout: .seconds(2)) == .online - ) - let lateEvents = await endpoint.healthEvents() - #expect( - await firstHealthEvent(in: lateEvents, timeout: .seconds(1)) == .online - ) - - await endpoint.close() - } - - @Test - func unmanagedRelayFailsAndManagedRelayFailoverBuildsSeparateAttempts() async throws { - let first = "https://use1-1.relay.lawrence.cmux.iroh.link/" - let second = "https://usw1-1.relay.lawrence.cmux.iroh.link/" - let endpoint = try await makeEndpoint(managedRelayURLs: [first, second]) - let identity = await endpoint.identity() - let now = Date() - let unknown = try CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.com/", - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(60) - ) - await #expect(throws: CmxIrohLibError.unmanagedRelayURL(unknown.value)) { - _ = try await endpoint.connect( - to: CmxIrohEndpointAddress(identity: identity, pathHints: [unknown]), - alpn: CmxIrohProtocolConfiguration.cmuxMobileV1.alpn - ) - } - - let hints = try [first, second].map { value in - try CmxIrohPathHint( - kind: .relayURL, - value: value, - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(60) - ) - } - let concrete = try #require(endpoint as? CmxIrohLibEndpoint) - let attempts = try await concrete.endpointAddresses( - CmxIrohEndpointAddress(identity: identity, pathHints: hints) - ) - #expect(attempts.map { $0.relayUrl() } == [first, second]) - await endpoint.close() - } - - @Test - func liveCustomProfileReplacesTheAllowlistWithoutChangingIdentity() async throws { - let endpoint = try await makeEndpoint(managedRelayURLs: []) - let concrete = try #require(endpoint as? CmxIrohLibEndpoint) - let identity = await endpoint.identity() - let customURL = "https://private.example.net:8443/" - let custom = try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: customURL)] - ) - - try await endpoint.replaceRelayProfile( - CmxIrohEndpointRelayProfile(customProfile: custom) - ) - - let now = Date() - let hint = try CmxIrohPathHint( - kind: .relayURL, - value: customURL, - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(60) - ) - let attempts = try await concrete.endpointAddresses( - CmxIrohEndpointAddress(identity: identity, pathHints: [hint]) - ) - #expect(attempts.map { $0.relayUrl() } == [customURL]) - #expect(await endpoint.identity() == identity) - - try await endpoint.replaceRelayProfile( - CmxIrohEndpointRelayProfile(managedRelayURLs: [], relays: []) - ) - await #expect(throws: CmxIrohLibError.unmanagedRelayURL(customURL)) { - _ = try await concrete.endpointAddresses( - CmxIrohEndpointAddress(identity: identity, pathHints: [hint]) - ) - } - await endpoint.close() - } - - @Test - func directOnlyIgnoresRelayPolicyReplacementAndRelayHints() async throws { - let endpoint = try await makeEndpoint( - managedRelayURLs: [], - transportVerificationMode: .directOnly - ) - let concrete = try #require(endpoint as? CmxIrohLibEndpoint) - let identity = await endpoint.identity() - let relayURL = "https://private.example.net:8443/" - let custom = try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: relayURL)] - ) - - try await endpoint.replaceRelayProfile( - CmxIrohEndpointRelayProfile(customProfile: custom) - ) - - let now = Date() - let relayHint = try CmxIrohPathHint( - kind: .relayURL, - value: relayURL, - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(60) - ) - let directHint = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.8.8:50906", - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(60) - ) - let attempts = try await concrete.endpointAddresses( - CmxIrohEndpointAddress( - identity: identity, - pathHints: [relayHint, directHint] - ) - ) - - #expect(attempts.count == 1) - #expect(attempts.first?.relayUrl() == nil) - #expect(attempts.first?.directAddresses() == [directHint.value]) - #expect(await endpoint.address().pathHints.allSatisfy { $0.kind != .relayURL }) - await endpoint.close() - } - - @Test - func relayOnlyIgnoresDirectAddressHintsAndAdvertisement() async throws { - let endpoint = try await makeEndpoint( - managedRelayURLs: [], - transportVerificationMode: .relayOnly - ) - let concrete = try #require(endpoint as? CmxIrohLibEndpoint) - let identity = await endpoint.identity() - let directHint = try CmxIrohPathHint( - kind: .directAddress, - value: "8.8.4.4:50906", - source: .native, - privacyScope: .publicInternet, - observedAt: Date(), - expiresAt: Date().addingTimeInterval(60) - ) - - let attempts = try await concrete.endpointAddresses( - CmxIrohEndpointAddress(identity: identity, pathHints: [directHint]) - ) - - #expect(attempts.count == 1) - #expect(attempts.first?.directAddresses().isEmpty == true) - #expect(await endpoint.localDirectAddresses().isEmpty) - #expect(await endpoint.address().pathHints.allSatisfy { $0.kind != .directAddress }) - await endpoint.close() - } - - @Test - func requiredBindPortFailsOnCollisionAndSucceedsAfterRelease() async throws { - let reservation = try reserveUDPPort() - let policy = try CmxIrohEndpointBindPolicy.required( - CmxIrohBindAddress(ipAddress: "127.0.0.1", port: reservation.port) - ) - - await #expect(throws: (any Error).self) { - _ = try await makeEndpoint(managedRelayURLs: [], bindPolicy: policy) - } - Darwin.close(reservation.descriptor) - - let endpoint = try await makeEndpoint( - managedRelayURLs: [], - bindPolicy: policy - ) - await endpoint.close() - } - - @Test - func preferredBindPortFallsBackWithoutKillingTheEndpoint() async throws { - let reservation = try reserveUDPPort() - defer { Darwin.close(reservation.descriptor) } - let policy = try CmxIrohEndpointBindPolicy.preferred( - CmxIrohBindAddress(ipAddress: "127.0.0.1", port: reservation.port) - ) - - let endpoint = try await makeEndpoint( - managedRelayURLs: [], - bindPolicy: policy - ) - - #expect(await endpoint.isHealthy()) - await endpoint.close() - } - - private func reserveUDPPort() throws -> (descriptor: Int32, port: UInt16) { - let descriptor = Darwin.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) - guard descriptor >= 0 else { throw currentPOSIXError() } - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size) - address.sin_family = sa_family_t(AF_INET) - address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - let bound = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.bind( - descriptor, - $0, - socklen_t(MemoryLayout<sockaddr_in>.size) - ) - } - } - guard bound == 0 else { - let error = currentPOSIXError() - Darwin.close(descriptor) - throw error - } - - var addressLength = socklen_t(MemoryLayout<sockaddr_in>.size) - let readAddress = withUnsafeMutablePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.getsockname(descriptor, $0, &addressLength) - } - } - guard readAddress == 0 else { - let error = currentPOSIXError() - Darwin.close(descriptor) - throw error - } - return (descriptor, UInt16(bigEndian: address.sin_port)) - } - - private func currentPOSIXError() -> POSIXError { - POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } - - private func firstHealthEvent( - in events: AsyncStream<CmxIrohEndpointHealthEvent>, - timeout: Duration - ) async -> CmxIrohEndpointHealthEvent? { - await withTaskGroup(of: CmxIrohEndpointHealthEvent?.self) { group in - group.addTask { - var iterator = events.makeAsyncIterator() - return await iterator.next() - } - group.addTask { - do { - try await ContinuousClock().sleep(for: timeout) - } catch { - return nil - } - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } - } - - private func makeEndpoint( - managedRelayURLs: Set<String>, - bindPolicy: CmxIrohEndpointBindPolicy = .ephemeral, - transportVerificationMode: CmxIrohTransportVerificationMode = .automatic - ) async throws -> any CmxIrohEndpoint { - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - bindPolicy: bindPolicy, - managedRelayURLs: managedRelayURLs, - relays: [] - ) - return try await CmxIrohLibEndpointFactory( - transportVerificationMode: transportVerificationMode - ).bind(configuration: configuration) - } - - private func makeUnmonitoredEndpoint( - transportVerificationMode: CmxIrohTransportVerificationMode - ) async throws -> (endpoint: CmxIrohLibEndpoint, observedAddress: EndpointAddr) { - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [], - relays: [] - ) - let driver = try await Endpoint.bind( - options: CmxIrohLibEndpointFactory.endpointOptions( - configuration: configuration, - socketAddress: nil, - relayMap: RelayMap.empty(), - transportVerificationMode: transportVerificationMode - ) - ) - let endpoint = CmxIrohLibEndpoint( - driver: driver, - identity: try CmxIrohLibIdentity.peerIdentity(driver.id()), - configuration: configuration, - transportVerificationMode: transportVerificationMode - ) - return (endpoint, driver.addr()) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOfflinePairingSessionsTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOfflinePairingSessionsTests.swift deleted file mode 100644 index d6ba0706..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOfflinePairingSessionsTests.swift +++ /dev/null @@ -1,496 +0,0 @@ -import CMUXMobileCore -@preconcurrency import CryptoKit -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohOfflinePairingSessionsTests { - @Test - func validInvitationIsConsumedExactlyOnce() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let invitation = try await fixture.invitation(from: sessions) - let credential = try invitation.admissionCredential( - initiatorAttestation: try fixture.initiatorAttestation() - ) - - let verified = try await sessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - #expect(verified.initiator.endpointID == fixture.initiator.endpointID) - await #expect(throws: CmxIrohOfflinePairingSessionError.sessionUnavailable) { - try await sessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - } - } - - @Test - func qrPossessionWithoutAnIndependentInitiatorAttestationFails() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let invitation = try await fixture.invitation(from: sessions) - let copiedMacCredential = try invitation.admissionCredential( - initiatorAttestation: invitation.acceptorAttestation - ) - - await #expect(throws: CmxIrohGrantVerifierError.identityMismatch) { - try await sessions.verifyAndConsume( - credential: copiedMacCredential, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - } - } - - @Test - func wrongProofDoesNotConsumeTheInvitation() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let invitation = try await fixture.invitation(from: sessions) - let initiatorAttestation = try fixture.initiatorAttestation() - let wrong = try CmxIrohAdmissionCredential.offlinePairing( - endpointAttestation: initiatorAttestation, - invitationID: CmxIrohResourceID(invitation.sessionID), - proof: Data(repeating: 0xff, count: 32) - ) - await #expect(throws: CmxIrohOfflinePairingSessionError.invalidProof) { - try await sessions.verifyAndConsume( - credential: wrong, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - } - - let correct = try invitation.admissionCredential( - initiatorAttestation: initiatorAttestation - ) - _ = try await sessions.verifyAndConsume( - credential: correct, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - } - - @Test - func concurrentReplayHasOneWinner() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let invitation = try await fixture.invitation(from: sessions) - let credential = try invitation.admissionCredential( - initiatorAttestation: try fixture.initiatorAttestation() - ) - - let successes = await withTaskGroup(of: Bool.self, returning: Int.self) { group in - for _ in 0 ..< 2 { - group.addTask { - do { - _ = try await sessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - return true - } catch { - return false - } - } - } - var count = 0 - for await value in group where value { count += 1 } - return count - } - #expect(successes == 1) - } - - @Test - func previousRotationKeyRemainsValidForCachedAttestations() async throws { - let fixture = try OfflineFixture(signingKey: .previous) - let sessions = fixture.sessions() - let invitation = try await fixture.invitation(from: sessions) - let credential = try invitation.admissionCredential( - initiatorAttestation: try fixture.initiatorAttestation() - ) - _ = try await sessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - } - - @Test - func liveTLSIdentitySubstitutionFailsWithoutConsuming() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let invitation = try await fixture.invitation(from: sessions) - let credential = try invitation.admissionCredential( - initiatorAttestation: try fixture.initiatorAttestation() - ) - await #expect(throws: CmxIrohGrantVerifierError.identityMismatch) { - try await sessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: fixture.acceptor.endpointID, - now: fixture.now - ) - } - _ = try await sessions.verifyAndConsume( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID, - now: fixture.now - ) - } - - @Test - func admissionControllerVerifiesOfflineProofBeforeBrokerTraffic() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let broker = OfflineAdmissionBroker( - responses: [.success(try fixture.discovery())] - ) - let controller = fixture.controller(sessions: sessions, broker: broker) - let invitation = try await fixture.invitation(from: sessions) - let attestation = try fixture.initiatorAttestation() - let wrongProof = try CmxIrohAdmissionCredential.offlinePairing( - endpointAttestation: attestation, - invitationID: CmxIrohResourceID(invitation.sessionID), - proof: Data(repeating: 0xff, count: 32) - ) - - #expect( - await controller.authorize( - credential: wrongProof, - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied(code: 1) - ) - #expect(await broker.callCount() == 0) - - let correct = try invitation.admissionCredential( - initiatorAttestation: attestation - ) - #expect( - await controller.authorize( - credential: correct, - authenticatedPeerID: fixture.acceptor.endpointID - ) == .denied(code: 1) - ) - #expect(await broker.callCount() == 0) - } - - @Test - func admissionControllerReturnsMonitoredOfflineLease() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let broker = OfflineAdmissionBroker( - responses: [.success(try fixture.discovery())] - ) - let controller = fixture.controller(sessions: sessions, broker: broker) - let invitation = try await fixture.invitation(from: sessions) - let credential = try invitation.admissionCredential( - initiatorAttestation: try fixture.initiatorAttestation() - ) - - let authorization = await controller.authorize( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID - ) - - guard case let .accepted(peer, onlineLease: lease?) = authorization else { - Issue.record("Expected monitored offline authorization") - return - } - #expect(peer.endpointID == fixture.initiator.endpointID) - #expect(lease.expiresAt == fixture.now.addingTimeInterval(3_600)) - #expect(await broker.callCount() == 1) - } - - @Test - func onlineMissingBindingDeniesAfterConsumingOfflineProof() async throws { - let fixture = try OfflineFixture() - let sessions = fixture.sessions() - let broker = OfflineAdmissionBroker( - responses: [.success(try fixture.discovery(includeInitiator: false))] - ) - let controller = fixture.controller(sessions: sessions, broker: broker) - let invitation = try await fixture.invitation(from: sessions) - let credential = try invitation.admissionCredential( - initiatorAttestation: try fixture.initiatorAttestation() - ) - - #expect( - await controller.authorize( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied(code: 1) - ) - #expect( - await controller.authorize( - credential: credential, - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied(code: 1) - ) - #expect(await broker.callCount() == 1) - } -} - -private struct OfflineFixture: Sendable { - enum SigningKey { case current, previous } - - let currentKey: Curve25519.Signing.PrivateKey - let previousKey: Curve25519.Signing.PrivateKey - let signingKey: SigningKey - let keySet: CmxIrohGrantVerificationKeySet - let initiator: CmxIrohEndpointExpectation - let acceptor: CmxIrohEndpointExpectation - let now = Date(timeIntervalSince1970: 1_800_000_000) - let nowSeconds: Int64 = 1_800_000_000 - let relayURL = "https://use1-1.relay.lawrence.cmux.iroh.link/" - - init(signingKey: SigningKey = .current) throws { - currentKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data((0 ..< 32).map(UInt8.init)) - ) - previousKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data(repeating: 3, count: 32) - ) - self.signingKey = signingKey - keySet = CmxIrohGrantVerificationKeySet( - version: 1, - currentKeyID: "current", - keys: [ - Self.verificationKey(id: "current", key: currentKey), - Self.verificationKey(id: "previous", key: previousKey), - ] - ) - initiator = CmxIrohEndpointExpectation( - bindingID: "123e4567-e89b-42d3-a456-426614174001", - deviceID: "123e4567-e89b-42d3-a456-426614174002", - endpointID: try CmxIrohPeerIdentity( - endpointID: currentKey.publicKey.rawRepresentation.hex - ), - identityGeneration: 1, - platform: .ios - ) - let macKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data(repeating: 9, count: 32) - ) - acceptor = CmxIrohEndpointExpectation( - bindingID: "123e4567-e89b-42d3-a456-426614174003", - deviceID: "123e4567-e89b-42d3-a456-426614174004", - endpointID: try CmxIrohPeerIdentity( - endpointID: macKey.publicKey.rawRepresentation.hex - ), - identityGeneration: 2, - platform: .mac - ) - } - - func sessions() -> CmxIrohOfflinePairingSessions { - CmxIrohOfflinePairingSessions( - pairingEnabled: true, - randomness: FixedRandomness(bytes: Data(repeating: 0x42, count: 32)), - makeUUID: { UUID(uuidString: "123e4567-e89b-42d3-a456-426614174010")! } - ) - } - - func invitation( - from sessions: CmxIrohOfflinePairingSessions - ) async throws -> CmxIrohOfflinePairingInvitation { - try await sessions.createInvitation( - acceptorAttestation: try attestation(for: acceptor), - keys: keySet, - acceptor: acceptor, - now: now - ) - } - - func initiatorAttestation() throws -> String { - try attestation(for: initiator) - } - - func controller( - sessions: CmxIrohOfflinePairingSessions, - broker: OfflineAdmissionBroker - ) -> CmxIrohAdmissionController { - let acceptor = grantPeer(for: acceptor, tag: "mac") - let registry = CmxIrohOnlineAdmissionRegistry( - broker: broker, - keys: keySet, - acceptor: acceptor, - managedRelayURLs: [relayURL], - clock: OfflineAdmissionFixedClock(now: now) - ) - return CmxIrohAdmissionController( - acceptor: acceptor, - pairingEnabled: true, - offlineSessions: sessions, - onlineRegistry: registry, - now: { now } - ) - } - - func discovery(includeInitiator: Bool = true) throws -> CmxIrohDiscoveryResponse { - var bindings: [[String: Any]] = [] - if includeInitiator { - bindings.append(bindingObject(endpoint: initiator, tag: "ios", pairable: true)) - } - bindings.append(bindingObject(endpoint: acceptor, tag: "mac", pairable: true)) - return try JSONDecoder().decode( - CmxIrohDiscoveryResponse.self, - from: JSONSerialization.data(withJSONObject: [ - "route_contract_version": 1, - "bindings": bindings, - "relay_fleet": [relayURL], - "lan_rendezvous": [ - "generation": 1, - "key": Data(repeating: 4, count: 32).base64URL, - ], - "grant_verification_keys": try JSONSerialization.jsonObject( - with: JSONEncoder().encode(keySet) - ), - ]) - ) - } - - private func attestation(for endpoint: CmxIrohEndpointExpectation) throws -> String { - let claims: [String: Any] = [ - "version": 1, - "jti": UUID().uuidString.lowercased(), - "sub": Data(repeating: 7, count: 32).base64URL, - "bindingId": endpoint.bindingID, - "deviceId": endpoint.deviceID, - "endpointId": endpoint.endpointID.endpointID, - "identityGeneration": endpoint.identityGeneration, - "platform": endpoint.platform.rawValue, - "iat": nowSeconds, - "nbf": nowSeconds - 5, - "exp": nowSeconds + 3_600, - "alpn": "cmux/mobile/1", - "scope": "cmux.offline-pair.same-account", - ] - let key = signingKey == .current ? currentKey : previousKey - let keyID = signingKey == .current ? "current" : "previous" - let header = try JSONSerialization.data( - withJSONObject: [ - "alg": "EdDSA", - "typ": "cmux-endpoint-attestation-v1+jwt", - "kid": keyID, - ], - options: [.sortedKeys] - ).base64URL - let body = try JSONSerialization.data( - withJSONObject: claims, - options: [.sortedKeys] - ).base64URL - let input = "\(header).\(body)" - let signature = try key.signature(for: Data(input.utf8)).base64URL - return "\(input).\(signature)" - } - - private static func verificationKey( - id: String, - key: Curve25519.Signing.PrivateKey - ) -> CmxIrohGrantVerificationKey { - let prefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, - 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]) - return CmxIrohGrantVerificationKey( - kid: id, - alg: "EdDSA", - spkiDerBase64: (prefix + key.publicKey.rawRepresentation).base64EncodedString() - ) - } - - private func grantPeer( - for endpoint: CmxIrohEndpointExpectation, - tag: String - ) -> CmxIrohGrantPeer { - CmxIrohGrantPeer( - bindingID: endpoint.bindingID, - deviceID: endpoint.deviceID, - tag: tag, - platform: endpoint.platform, - endpointID: endpoint.endpointID, - identityGeneration: endpoint.identityGeneration - ) - } - - private func bindingObject( - endpoint: CmxIrohEndpointExpectation, - tag: String, - pairable: Bool - ) -> [String: Any] { - [ - "binding_id": endpoint.bindingID, - "device_id": endpoint.deviceID, - "app_instance_id": endpoint.platform == .ios - ? "123e4567-e89b-42d3-a456-426614174030" - : "123e4567-e89b-42d3-a456-426614174031", - "tag": tag, - "platform": endpoint.platform.rawValue, - "display_name": NSNull(), - "endpoint_id": endpoint.endpointID.endpointID, - "identity_generation": endpoint.identityGeneration, - "pairing_enabled": pairable, - "capabilities": ["multistream-v1"], - "path_hints": [], - "last_seen_at": "2027-01-15T08:00:00Z", - ] - } -} - -private actor OfflineAdmissionBroker: CmxIrohDiscoveryServing { - private var responses: [Result<CmxIrohDiscoveryResponse, CmxIrohTrustBrokerClientError>] - private var calls = 0 - - init(responses: [Result<CmxIrohDiscoveryResponse, CmxIrohTrustBrokerClientError>]) { - self.responses = responses - } - - func discover() throws -> CmxIrohDiscoveryResponse { - calls += 1 - guard !responses.isEmpty else { throw CmxIrohTrustBrokerClientError.invalidResponse } - return try responses.removeFirst().get() - } - - func callCount() -> Int { calls } -} - -private struct OfflineAdmissionFixedClock: CmxIrohRelayClock { - let current: Date - - init(now: Date) { current = now } - func now() -> Date { current } - func sleep(until _: Date) async throws { - try await Task<Never, Never>.sleep(for: .seconds(24 * 60 * 60)) - } -} - -private struct FixedRandomness: CmxIrohRandomByteGenerating { - let bytes: Data - - func randomBytes(count: Int) throws -> Data { - guard bytes.count == count else { - throw CmxIrohOfflinePairingSessionError.randomnessUnavailable - } - return bytes - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - var hex: String { - map { String(format: "%02x", $0) }.joined() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift deleted file mode 100644 index 09203418..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryLeaseTests.swift +++ /dev/null @@ -1,296 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing - -@testable import CmuxIrohTransport - -extension CmxIrohOnlineAdmissionRegistryTests { - @Test - func connectivityAllowsLocallyValidGrantOffline() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.failure(.connectivity)]) - let registry = fixture.registry(broker: broker) - - let authorization = await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) - - #expect(authorization.isAccepted) - } - - @Test(arguments: [ - CmxIrohTrustBrokerClientError.missingAuthentication, - .invalidAuthentication, - .rejected(statusCode: 503, code: "unavailable"), - .invalidResponse, - ]) - func terminalBrokerFailuresDeny( - _ error: CmxIrohTrustBrokerClientError - ) async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.failure(error)]) - let registry = fixture.registry(broker: broker) - - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - } - - @Test - func contractAndFleetMismatchDeny() async throws { - let fixture = try OnlineAdmissionFixture() - let contractBroker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(routeContractVersion: 2))] - ) - let fleetBroker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(relayFleet: [fixture.otherRelayURL]))] - ) - - #expect( - await fixture.registry(broker: contractBroker).authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - #expect( - await fixture.registry(broker: fleetBroker).authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - } - - @Test - func missingOrAmbiguousBindingLearnsRevocationAcrossConnectivity() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery(includeInitiator: false)), - .failure(.connectivity), - ]) - let registry = fixture.registry(broker: broker) - - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - await broker.replaceResponses([.failure(.connectivity)]) - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - #expect(await broker.callCount() == 1) - - let ambiguousBroker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(duplicateInitiator: true))] - ) - #expect( - await fixture.registry(broker: ambiguousBroker).authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - } - - @Test - func onlineAcceptorPairingDisabledDeniesNewConnection() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(acceptorPairingEnabled: false))] - ) - let registry = fixture.registry(broker: broker) - - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - } - - @Test - func leaseClosesOnRefreshRevocationWithoutTouchingEndpoint() async throws { - let fixture = try OnlineAdmissionFixture() - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - .success(try fixture.discovery(includeInitiator: false)), - ]) - let endpoint = TestIrohEndpoint(identity: fixture.acceptor.endpointID) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] - ) - ) - _ = try await supervisor.activate() - let registry = fixture.registry(broker: broker, clock: clock) - let lease = try #require( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor(lease, connection: fixture.connection()) { - await closeRecorder.close() - } - await clock.waitUntilSleeping() - - clock.advance(by: 30) - await closeRecorder.waitUntilClosed() - - #expect(await closeRecorder.count() == 1) - let activeEndpoint = try await supervisor.activeEndpoint() - #expect(await activeEndpoint.identity() == fixture.acceptor.endpointID) - #expect(await endpoint.observedCloseCallCount() == 0) - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - await supervisor.deactivate() - } - - @Test - func connectivityDoesNotCloseActiveLease() async throws { - let fixture = try OnlineAdmissionFixture() - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - .failure(.connectivity), - ]) - let registry = fixture.registry(broker: broker, clock: clock) - let lease = try #require( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor(lease, connection: fixture.connection()) { - await closeRecorder.close() - } - await clock.waitUntilSleeping() - - clock.advance(by: 30) - await broker.waitForCallCount(2) - - #expect(await closeRecorder.count() == 0) - await registry.stop() - } - - @Test - func quickTransportRegistrationRetainsMonitorForConnectionLifetime() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - ]) - let registry = fixture.registry(broker: broker) - let lease = try #require( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).lease - ) - let connection = TestIrohConnection( - remoteIdentity: fixture.initiator.endpointID, - bidirectionalStreams: [] - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - - await registry.monitor(lease, connection: connection) { - await closeRecorder.close() - await connection.close(errorCode: 1, reason: "lease_invalidated") - } - - // Registering the application transport returns immediately. Revocation - // must still close the exact live connection after that handoff returns. - await registry.revoke(bindingID: fixture.initiator.bindingID) - await closeRecorder.waitUntilClosed() - - #expect(await closeRecorder.count() == 1) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func localRevokeImmediatelyClosesAndSticks() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - ]) - let registry = fixture.registry(broker: broker) - let lease = try #require( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor(lease, connection: fixture.connection()) { - await closeRecorder.close() - } - - await registry.revoke(bindingID: fixture.initiator.bindingID) - await closeRecorder.waitUntilClosed() - - #expect(await closeRecorder.count() == 1) - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - #expect(await broker.callCount() == 1) - } - - @Test - func grantExpiryClosesLeaseAndDeniesNewAdmission() async throws { - let fixture = try OnlineAdmissionFixture(grantLifetime: 20) - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [.failure(.connectivity)]) - let registry = fixture.registry(broker: broker, clock: clock) - let lease = try #require( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor(lease, connection: fixture.connection()) { - await closeRecorder.close() - } - await clock.waitUntilSleeping() - - clock.advance(by: 20) - await closeRecorder.waitUntilClosed() - - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - } -} - -extension CmxIrohOnlineAdmissionAuthorization { - var isAccepted: Bool { lease != nil } - - var lease: CmxIrohOnlineAdmissionLease? { - guard case let .accepted(lease) = self else { return nil } - return lease - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift deleted file mode 100644 index a292a78b..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift +++ /dev/null @@ -1,341 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing - -@testable import CmuxIrohTransport - -extension CmxIrohOnlineAdmissionRegistryTests { - @Test - func activeOfflinePairAcceptsUntilEarlierAttestationExpiry() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.success(try fixture.discovery())]) - let registry = fixture.registry(broker: broker) - - let authorization = await registry.authorizeOfflinePair( - try fixture.offlinePair(initiatorLifetime: 90, acceptorLifetime: 45) - ) - - let lease = try #require(authorization.lease) - #expect(lease.peer == CmxIrohAdmittedPeer(peer: fixture.initiator)) - #expect(lease.expiresAt == fixture.now.addingTimeInterval(45)) - #expect(await broker.callCount() == 1) - } - - @Test - func connectivityAllowsVerifiedOfflinePairUntilEarlierExpiry() async throws { - let fixture = try OnlineAdmissionFixture() - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [.failure(.connectivity)]) - let registry = fixture.registry(broker: broker, clock: clock) - let pair = try fixture.offlinePair(initiatorLifetime: 90, acceptorLifetime: 20) - let lease = try #require( - await registry.authorizeOfflinePair(pair).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor( - lease, - connection: fixture.connection() - ) { await closeRecorder.close() } - await clock.waitUntilSleeping() - - #expect(clock.sleepingDeadlines() == [fixture.now.addingTimeInterval(20)]) - clock.advance(by: 20) - await closeRecorder.waitUntilClosed() - - #expect(await closeRecorder.count() == 1) - #expect(await registry.authorizeOfflinePair(pair) == .denied) - #expect(await broker.callCount() == 1) - } - - @Test(arguments: [ - CmxIrohTrustBrokerClientError.missingAuthentication, - .invalidAuthentication, - .rejected(statusCode: 503, code: "unavailable"), - .invalidResponse, - ]) - func terminalBrokerFailuresDenyVerifiedOfflinePair( - _ error: CmxIrohTrustBrokerClientError - ) async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.failure(error)]) - - #expect( - await fixture.registry(broker: broker).authorizeOfflinePair( - try fixture.offlinePair() - ) == .denied - ) - } - - @Test - func contractAndFleetMismatchDenyVerifiedOfflinePair() async throws { - let fixture = try OnlineAdmissionFixture() - let contractBroker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(routeContractVersion: 2))] - ) - let fleetBroker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(relayFleet: [fixture.otherRelayURL]))] - ) - - #expect( - await fixture.registry(broker: contractBroker).authorizeOfflinePair( - try fixture.offlinePair() - ) == .denied - ) - #expect( - await fixture.registry(broker: fleetBroker).authorizeOfflinePair( - try fixture.offlinePair() - ) == .denied - ) - } - - @Test - func pairGrantStillRequiresExactSignedTagOnline() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(initiatorTag: "substituted"))] - ) - - #expect( - await fixture.registry(broker: broker).authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) == .denied - ) - } - - @Test - func missingOfflineBindingLearnsRevocationAcrossConnectivity() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery(includeInitiator: false))] - ) - let registry = fixture.registry(broker: broker) - let pair = try fixture.offlinePair() - - #expect(await registry.authorizeOfflinePair(pair) == .denied) - await broker.replaceResponses([.failure(.connectivity)]) - #expect(await registry.authorizeOfflinePair(pair) == .denied) - #expect(await broker.callCount() == 1) - } - - @Test - func offlineLeaseRefreshesAtSnapshotAgeThirtyAndClosesWithoutEndpointRestart() async throws { - let fixture = try OnlineAdmissionFixture() - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - .success(try fixture.discovery(includeInitiator: false)), - ]) - let endpoint = TestIrohEndpoint(identity: fixture.acceptor.endpointID) - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 6, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] - ) - ) - _ = try await supervisor.activate() - let registry = fixture.registry(broker: broker, clock: clock) - let lease = try #require( - await registry.authorizeOfflinePair( - try fixture.offlinePair(initiatorLifetime: 120, acceptorLifetime: 120) - ).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor( - lease, - connection: fixture.connection() - ) { await closeRecorder.close() } - await clock.waitUntilSleeping() - - #expect(clock.sleepingDeadlines() == [fixture.now.addingTimeInterval(30)]) - clock.advance(by: 30) - await closeRecorder.waitUntilClosed() - - #expect(await broker.callCount() == 2) - #expect(await closeRecorder.count() == 1) - let activeEndpoint = try await supervisor.activeEndpoint() - #expect(await activeEndpoint.identity() == fixture.acceptor.endpointID) - #expect(await endpoint.observedCloseCallCount() == 0) - await supervisor.deactivate() - } - - @Test - func forgedGrantCannotInduceBrokerTraffic() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.success(try fixture.discovery())]) - let registry = fixture.registry(broker: broker) - - let authorization = await registry.authorizePairGrant( - fixture.grant(signer: Curve25519.Signing.PrivateKey()), - authenticatedPeerID: fixture.initiator.endpointID - ) - - #expect(authorization == .denied) - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.acceptor.endpointID - ) == .denied - ) - #expect(await broker.callCount() == 0) - } - - @Test - func onlineSnapshotIsSharedForLessThanThirtySecondsOnly() async throws { - let fixture = try OnlineAdmissionFixture() - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - .success(try fixture.discovery()), - ]) - let registry = fixture.registry(broker: broker, clock: clock) - - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).isAccepted - ) - clock.advance(by: 29) - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).isAccepted - ) - #expect(await broker.callCount() == 1) - - clock.advance(by: 1) - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).isAccepted - ) - #expect(await broker.callCount() == 2) - } - - @Test - func concurrentValidAttemptsCoalesceOneRefresh() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery())], - suspended: true - ) - let registry = fixture.registry(broker: broker) - let grant = fixture.grant() - let initiatorEndpointID = fixture.initiator.endpointID - - async let first = registry.authorizePairGrant( - grant, - authenticatedPeerID: initiatorEndpointID - ) - async let second = registry.authorizePairGrant( - grant, - authenticatedPeerID: initiatorEndpointID - ) - await broker.waitUntilCalled() - #expect(await broker.callCount() == 1) - await broker.resume() - - #expect(await first.isAccepted) - #expect(await second.isAccepted) - #expect(await broker.callCount() == 1) - } - - @Test - func revokeDuringRefreshCannotAdmitTheStaleResult() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker( - responses: [.success(try fixture.discovery())], - suspended: true - ) - let registry = fixture.registry(broker: broker) - let grant = fixture.grant() - let initiatorEndpointID = fixture.initiator.endpointID - let initiatorBindingID = fixture.initiator.bindingID - - async let authorization = registry.authorizePairGrant( - grant, - authenticatedPeerID: initiatorEndpointID - ) - await broker.waitUntilCalled() - await registry.revoke(bindingID: initiatorBindingID) - await broker.resume() - - #expect(await authorization == .denied) - } - - @Test - func connectivityAfterPolicyUpdateCannotAdmitStaleAuthority() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker( - responses: [.failure(.connectivity)], - suspended: true - ) - let registry = fixture.registry(broker: broker) - let grant = fixture.grant() - let initiatorEndpointID = fixture.initiator.endpointID - let keySet = fixture.keySet - let replacementAcceptor = fixture.replacementAcceptor() - - async let authorization = registry.authorizePairGrant( - grant, - authenticatedPeerID: initiatorEndpointID - ) - await broker.waitUntilCalled() - await registry.update( - keys: keySet, - acceptor: replacementAcceptor - ) - await broker.resume() - - #expect(await authorization == .denied) - } - - @Test - func staleSuccessfulMonitorRefreshCannotExtendAcrossPolicyUpdate() async throws { - let fixture = try OnlineAdmissionFixture() - let clock = OnlineAdmissionManualClock(now: fixture.now) - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - .success(try fixture.discovery()), - ]) - let registry = fixture.registry(broker: broker, clock: clock) - let lease = try #require( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).lease - ) - let closeRecorder = OnlineAdmissionCloseRecorder() - await registry.monitor( - lease, - connection: fixture.connection() - ) { await closeRecorder.close() } - await clock.waitUntilSleeping() - await broker.suspend() - - clock.advance(by: 30) - await broker.waitForCallCount(2) - await registry.update( - keys: fixture.keySet, - acceptor: fixture.replacementAcceptor() - ) - await broker.resume() - for _ in 0 ..< 1_024 { - if await closeRecorder.count() > 0 || !clock.sleepingDeadlines().isEmpty { - break - } - await Task.yield() - } - - #expect(await closeRecorder.count() == 1) - #expect(clock.sleepingDeadlines().isEmpty) - } - -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryTests.swift deleted file mode 100644 index 3c5f4c52..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryTests.swift +++ /dev/null @@ -1,489 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohOnlineAdmissionRegistryTests { - @Test - func activeSignedBindingAcceptsAfterOnlineValidation() async throws { - let fixture = try OnlineAdmissionFixture() - let broker = OnlineAdmissionBroker(responses: [.success(try fixture.discovery())]) - let registry = fixture.registry(broker: broker) - - let authorization = await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ) - - let lease = try #require(authorization.lease) - #expect(lease.peer == CmxIrohAdmittedPeer(peer: fixture.initiator)) - #expect(lease.expiresAt == fixture.now.addingTimeInterval(300)) - #expect(await broker.callCount() == 1) - } - - @Test - func cachedDiscoveryMissRefreshesBeforeDenyingNewBinding() async throws { - let fixture = try OnlineAdmissionFixture() - let replacement = try fixture.replacementInitiator() - let broker = OnlineAdmissionBroker(responses: [ - .success(try fixture.discovery()), - .success(try fixture.discovery(initiator: replacement)), - ]) - let registry = fixture.registry(broker: broker) - - #expect( - await registry.authorizePairGrant( - fixture.grant(), - authenticatedPeerID: fixture.initiator.endpointID - ).isAccepted - ) - #expect( - await registry.authorizePairGrant( - fixture.grant(initiator: replacement), - authenticatedPeerID: replacement.endpointID - ).isAccepted - ) - #expect(await broker.callCount() == 2) - } -} - -actor OnlineAdmissionBroker: CmxIrohRegistryServing { - private var responses: [Result<CmxIrohDiscoveryResponse, CmxIrohTrustBrokerClientError>] - private var calls = 0 - private var suspended: Bool - private var resumeWaiters: [CheckedContinuation<Void, Never>] = [] - private var callWaiters: [(Int, CheckedContinuation<Void, Never>)] = [] - - init( - responses: [Result<CmxIrohDiscoveryResponse, CmxIrohTrustBrokerClientError>], - suspended: Bool = false - ) { - self.responses = responses - self.suspended = suspended - } - - func discover() async throws -> CmxIrohDiscoveryResponse { - calls += 1 - releaseCallWaiters() - if suspended { - await withCheckedContinuation { resumeWaiters.append($0) } - } - guard !responses.isEmpty else { throw CmxIrohTrustBrokerClientError.invalidResponse } - return try responses.removeFirst().get() - } - - func issuePairGrant( - initiatorBindingID _: String, - acceptorBindingID _: String - ) async throws -> CmxIrohPairGrantResponse { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - - func callCount() -> Int { calls } - - func waitUntilCalled() async { await waitForCallCount(1) } - - func waitForCallCount(_ count: Int) async { - if calls >= count { return } - await withCheckedContinuation { callWaiters.append((count, $0)) } - } - - func resume() { - suspended = false - let waiters = resumeWaiters - resumeWaiters.removeAll() - for waiter in waiters { waiter.resume() } - } - - func suspend() { - suspended = true - } - - func replaceResponses( - _ responses: [Result<CmxIrohDiscoveryResponse, CmxIrohTrustBrokerClientError>] - ) { - self.responses = responses - } - - private func releaseCallWaiters() { - let ready = callWaiters.filter { calls >= $0.0 } - callWaiters.removeAll { calls >= $0.0 } - for waiter in ready { waiter.1.resume() } - } -} - -final class OnlineAdmissionManualClock: CmxIrohRelayClock, @unchecked Sendable { - private struct State { - var date: Date - var sleepers: [UUID: (Date, CheckedContinuation<Void, any Error>)] = [:] - var sleepWaiters: [CheckedContinuation<Void, Never>] = [] - } - - private let lock = NSLock() - private var state: State - - init(now: Date) { state = State(date: now) } - - func now() -> Date { withLock { $0.date } } - - func sleep(until deadline: Date) async throws { - let id = UUID() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - let result = withLock { state -> ( - immediate: Bool, - waiters: [CheckedContinuation<Void, Never>] - ) in - guard deadline > state.date else { return (true, []) } - state.sleepers[id] = (deadline, continuation) - let waiters = state.sleepWaiters - state.sleepWaiters.removeAll() - return (false, waiters) - } - for waiter in result.waiters { waiter.resume() } - if result.immediate { continuation.resume() } - } - } onCancel: { - self.cancel(id) - } - } - - func waitUntilSleeping() async { - let sleeping = withLock { !$0.sleepers.isEmpty } - if sleeping { return } - await withCheckedContinuation { continuation in - withLock { $0.sleepWaiters.append(continuation) } - } - } - - func sleepingDeadlines() -> [Date] { - withLock { $0.sleepers.values.map(\.0).sorted() } - } - - func advance(by seconds: TimeInterval) { - let ready = withLock { state -> [(Date, CheckedContinuation<Void, any Error>)] in - state.date = state.date.addingTimeInterval(seconds) - let ready = state.sleepers.filter { $0.value.0 <= state.date } - for id in ready.keys { state.sleepers[id] = nil } - return Array(ready.values) - } - for sleeper in ready { sleeper.1.resume() } - } - - private func cancel(_ id: UUID) { - withLock { $0.sleepers.removeValue(forKey: id) }? - .1.resume(throwing: CancellationError()) - } - - private func withLock<T>(_ body: (inout State) -> T) -> T { - lock.lock() - defer { lock.unlock() } - return body(&state) - } -} - -actor OnlineAdmissionCloseRecorder { - private var closes = 0 - private var waiters: [CheckedContinuation<Void, Never>] = [] - - func close() { - closes += 1 - let current = waiters - waiters.removeAll() - for waiter in current { waiter.resume() } - } - - func count() -> Int { closes } - - func waitUntilClosed() async { - if closes > 0 { return } - await withCheckedContinuation { waiters.append($0) } - } -} - -struct OnlineAdmissionFixture { - let signingKey: Curve25519.Signing.PrivateKey - let keySet: CmxIrohGrantVerificationKeySet - let initiator: CmxIrohGrantPeer - let acceptor: CmxIrohGrantPeer - let now = Date(timeIntervalSince1970: 1_800_000_000) - let grantLifetime: Int64 - let relayURL = "https://use1-1.relay.lawrence.cmux.iroh.link/" - let otherRelayURL = "https://euc1-1.relay.lawrence.cmux.iroh.link/" - - init(grantLifetime: Int64 = 300) throws { - self.grantLifetime = grantLifetime - signingKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data((0 ..< 32).map(UInt8.init)) - ) - let acceptorKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data(repeating: 7, count: 32) - ) - initiator = CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174001", - deviceID: "123e4567-e89b-42d3-a456-426614174002", - tag: "ios", - platform: .ios, - endpointID: try CmxIrohPeerIdentity( - endpointID: signingKey.publicKey.rawRepresentation.hex - ), - identityGeneration: 1 - ) - acceptor = CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174003", - deviceID: "123e4567-e89b-42d3-a456-426614174004", - tag: "mac", - platform: .mac, - endpointID: try CmxIrohPeerIdentity( - endpointID: acceptorKey.publicKey.rawRepresentation.hex - ), - identityGeneration: 2 - ) - let prefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, - 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]) - keySet = CmxIrohGrantVerificationKeySet( - version: 1, - currentKeyID: "current", - keys: [CmxIrohGrantVerificationKey( - kid: "current", - alg: "EdDSA", - spkiDerBase64: ( - prefix + signingKey.publicKey.rawRepresentation - ).base64EncodedString() - )] - ) - } - - func registry( - broker: OnlineAdmissionBroker, - clock: (any CmxIrohRelayClock)? = nil - ) -> CmxIrohOnlineAdmissionRegistry { - CmxIrohOnlineAdmissionRegistry( - broker: broker, - keys: keySet, - acceptor: acceptor, - managedRelayURLs: [relayURL], - clock: clock ?? FixedOnlineAdmissionClock(now: now) - ) - } - - func connection() -> TestIrohConnection { - TestIrohConnection( - remoteIdentity: initiator.endpointID, - bidirectionalStreams: [] - ) - } - - func grant( - signer: Curve25519.Signing.PrivateKey? = nil, - initiator grantInitiator: CmxIrohGrantPeer? = nil - ) -> String { - let grantInitiator = grantInitiator ?? initiator - let header = try! JSONSerialization.data(withJSONObject: [ - "alg": "EdDSA", - "typ": "cmux-pair-grant+jwt", - "kid": "current", - ], options: [.sortedKeys]) - let nowSeconds = Int64(now.timeIntervalSince1970) - let payload = try! JSONSerialization.data(withJSONObject: [ - "jti": "123e4567-e89b-42d3-a456-426614174010", - "iat": nowSeconds, - "nbf": nowSeconds, - "exp": nowSeconds + grantLifetime, - "alpn": "cmux/mobile/1", - "scope": "cmux.mobile.attach", - "initiator": peerObject(grantInitiator), - "acceptor": peerObject(acceptor), - ], options: [.sortedKeys]) - let encodedHeader = header.base64URL - let encodedPayload = payload.base64URL - let input = Data("\(encodedHeader).\(encodedPayload)".utf8) - let signature = try! (signer ?? signingKey).signature(for: input) - return "\(encodedHeader).\(encodedPayload).\(signature.base64URL)" - } - - func replacementInitiator() throws -> CmxIrohGrantPeer { - let endpointKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data(repeating: 11, count: 32) - ) - return CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174091", - deviceID: "123e4567-e89b-42d3-a456-426614174092", - tag: "ios-reinstalled", - platform: .ios, - endpointID: try CmxIrohPeerIdentity( - endpointID: endpointKey.publicKey.rawRepresentation.hex - ), - identityGeneration: 1 - ) - } - - func replacementAcceptor() -> CmxIrohGrantPeer { - CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174099", - deviceID: acceptor.deviceID, - tag: acceptor.tag, - platform: acceptor.platform, - endpointID: acceptor.endpointID, - identityGeneration: acceptor.identityGeneration - ) - } - - func offlinePair( - initiatorLifetime: Int64 = 300, - acceptorLifetime: Int64 = 300 - ) throws -> CmxIrohVerifiedOfflinePair { - CmxIrohVerifiedOfflinePair( - initiator: try attestationClaims( - peer: initiator, - lifetime: initiatorLifetime, - attestationID: "123e4567-e89b-42d3-a456-426614174020" - ), - acceptor: try attestationClaims( - peer: acceptor, - lifetime: acceptorLifetime, - attestationID: "123e4567-e89b-42d3-a456-426614174021" - ) - ) - } - - func discovery( - routeContractVersion: Int = 1, - relayFleet: [String]? = nil, - includeInitiator: Bool = true, - duplicateInitiator: Bool = false, - acceptorPairingEnabled: Bool = true, - initiatorTag: String? = nil, - initiator discoveryInitiator: CmxIrohGrantPeer? = nil - ) throws -> CmxIrohDiscoveryResponse { - var bindings: [[String: Any]] = [] - if includeInitiator { - let discoveryInitiator = discoveryInitiator ?? initiator - let discoveredInitiator = CmxIrohGrantPeer( - bindingID: discoveryInitiator.bindingID, - deviceID: discoveryInitiator.deviceID, - tag: initiatorTag ?? discoveryInitiator.tag, - platform: discoveryInitiator.platform, - endpointID: discoveryInitiator.endpointID, - identityGeneration: discoveryInitiator.identityGeneration - ) - bindings.append(bindingObject(peer: discoveredInitiator, pairingEnabled: true)) - if duplicateInitiator { - bindings.append(bindingObject( - peer: CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174098", - deviceID: initiator.deviceID, - tag: initiator.tag, - platform: initiator.platform, - endpointID: initiator.endpointID, - identityGeneration: initiator.identityGeneration - ), - pairingEnabled: true, - appInstanceID: "123e4567-e89b-42d3-a456-426614174099" - )) - } - } - bindings.append(bindingObject( - peer: acceptor, - pairingEnabled: acceptorPairingEnabled - )) - let response: [String: Any] = [ - "route_contract_version": routeContractVersion, - "bindings": bindings, - "relay_fleet": relayFleet ?? [relayURL], - "lan_rendezvous": [ - "generation": 1, - "key": Data(repeating: 3, count: 32).base64URL, - ], - "grant_verification_keys": try JSONSerialization.jsonObject( - with: JSONEncoder().encode(keySet) - ), - ] - return try JSONDecoder().decode( - CmxIrohDiscoveryResponse.self, - from: JSONSerialization.data(withJSONObject: response) - ) - } - - private func bindingObject( - peer: CmxIrohGrantPeer, - pairingEnabled: Bool, - appInstanceID: String = "123e4567-e89b-42d3-a456-426614174005" - ) -> [String: Any] { - [ - "binding_id": peer.bindingID, - "device_id": peer.deviceID, - "app_instance_id": appInstanceID, - "tag": peer.tag, - "platform": peer.platform.rawValue, - "display_name": NSNull(), - "endpoint_id": peer.endpointID.endpointID, - "identity_generation": peer.identityGeneration, - "pairing_enabled": pairingEnabled, - "capabilities": ["multistream-v1"], - "path_hints": [], - "last_seen_at": "2027-01-15T08:00:00Z", - ] - } - - private func peerObject(_ peer: CmxIrohGrantPeer) -> [String: Any] { - [ - "bindingId": peer.bindingID, - "deviceId": peer.deviceID, - "tag": peer.tag, - "platform": peer.platform.rawValue, - "endpointId": peer.endpointID.endpointID, - "identityGeneration": peer.identityGeneration, - ] - } - - private func attestationClaims( - peer: CmxIrohGrantPeer, - lifetime: Int64, - attestationID: String - ) throws -> CmxIrohEndpointAttestationClaims { - let seconds = Int64(now.timeIntervalSince1970) - return try JSONDecoder().decode( - CmxIrohEndpointAttestationClaims.self, - from: JSONSerialization.data(withJSONObject: [ - "version": 1, - "jti": attestationID, - "sub": Data(repeating: 7, count: 32).base64URL, - "bindingId": peer.bindingID, - "deviceId": peer.deviceID, - "endpointId": peer.endpointID.endpointID, - "identityGeneration": peer.identityGeneration, - "platform": peer.platform.rawValue, - "iat": seconds, - "nbf": seconds, - "exp": seconds + lifetime, - "alpn": "cmux/mobile/1", - "scope": "cmux.offline-pair.same-account", - ]) - ) - } -} - -struct FixedOnlineAdmissionClock: CmxIrohRelayClock { - let current: Date - init(now: Date) { current = now } - func now() -> Date { current } - func sleep(until _: Date) async throws { - try await Task<Never, Never>.sleep(for: .seconds(24 * 60 * 60)) - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - var hex: String { map { String(format: "%02x", $0) }.joined() } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift deleted file mode 100644 index 84ef60b1..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPendingRevocationOutboxTests.swift +++ /dev/null @@ -1,120 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh pending revocation outbox") -struct CmxIrohPendingRevocationOutboxTests { - private let accountID = "account-a" - private let tag = "cmux-ios-v0" - private let bindingID = "123e4567-e89b-42d3-a456-426614174020" - - @Test("pending revocations survive recreation in device-only storage without auth tokens") - func durableDeviceOnlyStorageContainsNoTokens() async throws { - let store = TestSecureCredentialStore() - let outbox = CmxIrohPendingRevocationOutbox(secureStore: store) - let pending = try revocation() - - try await outbox.enqueue(pending) - - let recreated = CmxIrohPendingRevocationOutbox(secureStore: store) - #expect(try await recreated.pending(accountID: accountID) == [pending]) - #expect( - await store.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - let stored = try #require(await store.onlyStoredData()) - let encoded = String(decoding: stored, as: UTF8.self) - #expect(!encoded.contains("access-token-secret")) - #expect(!encoded.contains("refresh-token-secret")) - } - - @Test(arguments: [ - CmxIrohTrustBrokerClientError.connectivity, - .rejected(statusCode: 503, code: "unavailable"), - ]) - func transientFailureRetainsPendingRevocation( - _ failure: CmxIrohTrustBrokerClientError - ) async throws { - let store = TestSecureCredentialStore() - let outbox = CmxIrohPendingRevocationOutbox(secureStore: store) - let pending = try revocation() - let broker = PendingRevocationBroker(error: failure) - try await outbox.enqueue(pending) - - do { - try await outbox.revokePending( - accountID: accountID, - beforeRegisteringTag: tag, - using: broker - ) - Issue.record("Expected revocation failure") - } catch let error as CmxIrohTrustBrokerClientError { - #expect(error == failure) - } - - #expect(try await outbox.pending(accountID: accountID) == [pending]) - #expect(await broker.revokedBindingIDs() == [bindingID]) - } - - @Test("confirmed revocation removes only that account and drains older build tags") - func confirmedRevocationRemovesAccountEntriesAcrossTags() async throws { - let store = TestSecureCredentialStore() - let outbox = CmxIrohPendingRevocationOutbox(secureStore: store) - let current = try revocation() - let oldTag = try CmxIrohPendingRevocation( - accountID: accountID, - tag: "cmux-ios-v0-old", - bindingID: "123e4567-e89b-42d3-a456-426614174021" - ) - let otherAccount = try CmxIrohPendingRevocation( - accountID: "account-b", - tag: tag, - bindingID: "123e4567-e89b-42d3-a456-426614174022" - ) - try await outbox.enqueue(oldTag) - try await outbox.enqueue(current) - try await outbox.enqueue(current) - try await outbox.enqueue(otherAccount) - let broker = PendingRevocationBroker() - - try await outbox.revokePending( - accountID: accountID, - beforeRegisteringTag: tag, - using: broker - ) - - #expect( - await broker.revokedBindingIDs() - == [current.bindingID, oldTag.bindingID] - ) - #expect(try await outbox.pending(accountID: accountID).isEmpty) - #expect( - try await outbox.pending(accountID: otherAccount.accountID) - == [otherAccount] - ) - } - - private func revocation() throws -> CmxIrohPendingRevocation { - try CmxIrohPendingRevocation( - accountID: accountID, - tag: tag, - bindingID: bindingID - ) - } -} - -private actor PendingRevocationBroker: CmxIrohBindingRevoking { - private let error: CmxIrohTrustBrokerClientError? - private var bindingIDs: [String] = [] - - init(error: CmxIrohTrustBrokerClientError? = nil) { - self.error = error - } - - func revoke(bindingID: String) throws { - bindingIDs.append(bindingID) - if let error { throw error } - } - - func revokedBindingIDs() -> [String] { bindingIDs } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift deleted file mode 100644 index fdff9085..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPersistenceLifecycleRaceTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh persistence lifecycle races") -struct CmxIrohPersistenceLifecycleRaceTests { - @Test("sign-out fences a suspended relay credential write") - func brokerRepositoryDeactivationFencesSuspendedWrite() async throws { - let suiteName = "CmxIrohPersistenceLifecycleRaceTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - let store = TestControllableSecureCredentialStore() - let repository = CmxIrohBrokerCredentialRepository( - secureStore: store, - installState: CmxIrohUserDefaultsInstallStateStore(defaults: defaults) - ) - let fixture = try ClientRuntimeTestFixture() - let binding = CmxIrohBrokerBindingMetadata(binding: fixture.binding) - let relayFleet = fixture.configuration.managedRelayURLs - try await repository.saveBinding(binding, accountID: "account-a") - await store.suspendNextWrite() - let save = Task { - try await repository.saveRelayCredential( - fixture.relayResponse(), - accountID: "account-a", - binding: binding, - expectedRelayFleet: relayFleet, - now: fixture.now - ) - } - await store.waitUntilWriteIsSuspended() - await store.suspendNextDeleteAll() - - let deactivate = Task { try await repository.deactivate() } - #expect( - await waitsForLifecycleCancellation { - _ = try await repository.loadBinding( - accountID: "account-a", - appInstanceID: binding.appInstanceID - ) - } - ) - await store.resumeSuspendedWrite() - await #expect(throws: CancellationError.self) { try await save.value } - await store.waitUntilDeleteAllIsSuspended() - await store.resumeSuspendedDeleteAll() - try await deactivate.value - - #expect(await store.recordCount() == 0) - #expect(await store.deleteAllCount() == 2) - } - - @Test("sign-out fences a suspended host-policy write") - func hostPolicyDeactivationFencesSuspendedWrite() async throws { - let fixture = try HostPolicyCacheTestFixture() - let expectation = try fixture.expectation() - let store = TestControllableSecureCredentialStore() - let cache = CmxIrohHostPolicyCache(secureStore: store) - await store.suspendNextWrite() - let save = Task { - try await cache.save( - fixture.policy(), - for: expectation, - now: fixture.now - ) - } - await store.waitUntilWriteIsSuspended() - await store.suspendNextDeleteAll() - let now = fixture.now - - let deactivate = Task { try await cache.deactivate() } - #expect( - await waitsForLifecycleCancellation { - _ = try await cache.load(for: expectation, now: now) - } - ) - await store.resumeSuspendedWrite() - await #expect(throws: CancellationError.self) { try await save.value } - await store.waitUntilDeleteAllIsSuspended() - await store.resumeSuspendedDeleteAll() - try await deactivate.value - - #expect(await store.recordCount() == 0) - #expect(await store.deleteAllCount() == 1) - } -} - -private func waitsForLifecycleCancellation( - _ operation: @escaping @Sendable () async throws -> Void -) async -> Bool { - await withTaskGroup(of: Bool.self) { group in - group.addTask { - while !Task.isCancelled { - do { - try await operation() - } catch is CancellationError { - return true - } catch { - return false - } - await Task.yield() - } - return false - } - group.addTask { - do { - try await ContinuousClock().sleep(for: .seconds(1)) - } catch { - return false - } - return false - } - let result = await group.next() ?? false - group.cancelAll() - return result - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift deleted file mode 100644 index 4b9f5804..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift +++ /dev/null @@ -1,597 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Darwin -import Foundation -import Testing -@testable import CmuxIrohTransport - -/// Release-gate coverage for the provider-neutral custom-private-path contract. -/// -/// The successful case traverses a real non-loopback host interface. It proves -/// the app contract without claiming that CI owns an external VPN tunnel. -@Suite(.serialized) -struct CmxIrohPrivatePathTransportGateTests { - private struct BoundServer { - let endpoint: any CmxIrohEndpoint - let port: UInt16 - } - - private enum GateError: Error { - case connectionTimedOut - case noPrivateIPv4Interface - case portAllocationFailed - } - - @Test - func brokerAuthorizedCustomAddressCarriesPrivateRPC() async throws { - let now = Date() - let fixture = try RegistryFixture( - now: now, - initiatorSecretKey: Data(repeating: 1, count: 32), - acceptorSecretKey: Data(repeating: 10, count: 32) - ) - let ipAddress = try privateIPv4Address() - let profile = try privateNetworkProfile(id: "release-gate") - let customPath = try CmxIrohCustomPrivatePathBootstrap( - address: CmxIrohCustomPrivateAddress(ipAddress), - networkProfile: profile - ) - let clientSupervisor = try await realClientSupervisor(fixture: fixture) - let server = try await realServerEndpoint( - fixture: fixture, - ipAddress: ipAddress - ) - let serverEndpoint = server.endpoint - let port = server.port - - do { - let clientEndpoint = try await clientSupervisor.activeEndpoint() - #expect(await clientEndpoint.identity() == fixture.initiator.endpointID) - #expect(await serverEndpoint.identity() == fixture.acceptor.endpointID) - - let discovery = try fixture.discovery( - targetHints: [], - targetDirectPorts: ["ipv4": Int(port)], - targetLastSeenAt: now - ) - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 300 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: clientSupervisor, - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 1, - activeNetworkProfiles: [profile] - ) - }, - customPrivateFallback: { deviceID in - guard CmxIrohDeviceID(deviceID) - == CmxIrohDeviceID(fixture.acceptor.deviceID) else { return [] } - return [customPath] - }, - now: { now } - ) - let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.dialPlan.publicPaths.isEmpty) - #expect(context.dialPlan.privateFallbackPaths.map(\.value) == [ - "\(ipAddress):\(port)", - ]) - #expect(context.dialPlan.privateFallbackPaths.allSatisfy { - $0.source == .customVPN - && $0.privacyScope == .privateNetwork - && $0.networkProfile == profile - }) - #expect(context.credential.kind == .pairGrant) - - let authorizer = admissionController( - fixture: fixture, - broker: broker, - discovery: discovery, - now: now - ) - let clientSession = try CmxIrohClientSession( - endpoint: clientEndpoint, - targetIdentity: fixture.acceptor.endpointID, - dialPlan: context.dialPlan, - credential: context.credential, - privateFallbackAuthorization: context.privateFallbackAuthorization, - privateFallbackValidator: provider - ) - let serverSession = try await connect( - clientSession: clientSession, - serverEndpoint: serverEndpoint, - authorizer: authorizer - ) - - let admittedPeer = try await serverSession.admittedPeerContext() - #expect(admittedPeer.endpointID == fixture.initiator.endpointID) - #expect(try await selectedPrivatePath(clientSession)) - - let request = Data( - #"{"jsonrpc":"2.0","id":1,"method":"cmux.privatePath.probe"}"#.utf8 - ) - try await clientSession.sendControl(request) - #expect(try await serverSession.receiveControl(maximumByteCount: 4_096) == request) - - let response = Data( - #"{"jsonrpc":"2.0","id":1,"result":{"path":"private_network"}}"#.utf8 - ) - try await serverSession.sendControl(response) - #expect(try await clientSession.receiveControl(maximumByteCount: 4_096) == response) - - await clientSession.close() - await serverSession.close() - } catch { - await clientSupervisor.deactivate() - await serverEndpoint.close() - throw error - } - await clientSupervisor.deactivate() - await serverEndpoint.close() - } - - @Test - func wrongEndpointIdentityCannotUseTheLivePrivateCoordinate() async throws { - let now = Date() - let fixture = try RegistryFixture( - now: now, - initiatorSecretKey: Data(repeating: 2, count: 32), - acceptorSecretKey: Data(repeating: 11, count: 32) - ) - let ipAddress = try privateIPv4Address() - let clientSupervisor = try await realClientSupervisor(fixture: fixture) - let server = try await realServerEndpoint( - fixture: fixture, - ipAddress: ipAddress - ) - let serverEndpoint = server.endpoint - let port = server.port - let clientEndpoint = try await clientSupervisor.activeEndpoint() - let hint = try privateHint( - ipAddress: ipAddress, - port: port, - profile: privateNetworkProfile(id: "wrong-identity"), - now: now - ) - let wrongIdentity = try peerIdentity(secretByte: 8) - - #expect(wrongIdentity != fixture.acceptor.endpointID) - let outcome = await connectionAttemptOutcome( - endpoint: clientEndpoint, - address: CmxIrohEndpointAddress( - identity: wrongIdentity, - pathHints: [hint] - ) - ) - #expect(outcome == .observationElapsed || outcome == .remoteIdentityMismatch) - - await clientSupervisor.deactivate() - await serverEndpoint.close() - } - - @Test - func brokerWrongPortCannotReachTheLivePrivateEndpoint() async throws { - let now = Date() - let fixture = try RegistryFixture( - now: now, - initiatorSecretKey: Data(repeating: 3, count: 32), - acceptorSecretKey: Data(repeating: 12, count: 32) - ) - let ipAddress = try privateIPv4Address() - let server = try await realServerEndpoint( - fixture: fixture, - ipAddress: ipAddress - ) - let serverEndpoint = server.endpoint - let serverPort = server.port - let wrongPort = try differentAvailableUDPPort( - ipAddress: ipAddress, - excluding: serverPort - ) - let profile = try privateNetworkProfile(id: "wrong-port") - let customPath = try CmxIrohCustomPrivatePathBootstrap( - address: CmxIrohCustomPrivateAddress(ipAddress), - networkProfile: profile - ) - let clientSupervisor = try await realClientSupervisor(fixture: fixture) - - do { - let discovery = try fixture.discovery( - targetHints: [], - targetDirectPorts: ["ipv4": Int(wrongPort)], - targetLastSeenAt: now - ) - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 300 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: clientSupervisor, - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 1, - activeNetworkProfiles: [profile] - ) - }, - customPrivateFallback: { _ in [customPath] }, - now: { now } - ) - let context = try await provider.context(for: fixture.request(hints: [])) - let clientEndpoint = try await clientSupervisor.activeEndpoint() - - #expect(context.dialPlan.privateFallbackPaths.map(\.value) == [ - "\(ipAddress):\(wrongPort)", - ]) - let outcome = await connectionAttemptOutcome( - endpoint: clientEndpoint, - address: CmxIrohEndpointAddress( - identity: fixture.acceptor.endpointID, - pathHints: context.dialPlan.privateFallbackPaths - ) - ) - #expect(outcome == .observationElapsed || outcome == .dialFailed) - } catch { - await clientSupervisor.deactivate() - await serverEndpoint.close() - throw error - } - - await clientSupervisor.deactivate() - await serverEndpoint.close() - } - - @Test - func inactiveCustomRouteFailsBeforeDial() async throws { - let now = Date() - let fixture = try RegistryFixture(now: now) - let ipAddress = try privateIPv4Address() - let port = try availableUDPPort(ipAddress: ipAddress) - let profile = try privateNetworkProfile(id: "inactive-route") - let customPath = try CmxIrohCustomPrivatePathBootstrap( - address: CmxIrohCustomPrivateAddress(ipAddress), - networkProfile: profile - ) - let clientSupervisor = try await realClientSupervisor(fixture: fixture) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - targetDirectPorts: ["ipv4": Int(port)], - targetLastSeenAt: now - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 300 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: clientSupervisor, - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 2, - activeNetworkProfiles: [] - ) - }, - customPrivateFallback: { _ in [customPath] }, - now: { now } - ) - - do { - let context = try await provider.context(for: fixture.request(hints: [])) - #expect(context.dialPlan.publicPaths.isEmpty) - #expect(context.dialPlan.privateFallbackPaths.isEmpty) - #expect(context.privateFallbackAuthorization == nil) - let endpoint = try await clientSupervisor.activeEndpoint() - let session = try CmxIrohClientSession( - endpoint: endpoint, - targetIdentity: fixture.acceptor.endpointID, - dialPlan: context.dialPlan, - credential: context.credential, - privateFallbackAuthorization: context.privateFallbackAuthorization, - privateFallbackValidator: provider - ) - await #expect(throws: CmxIrohRegistryContextError.dialPlanUnavailable) { - try await session.connect() - } - await session.close() - } catch { - await clientSupervisor.deactivate() - throw error - } - await clientSupervisor.deactivate() - } - - private func realClientSupervisor( - fixture: RegistryFixture - ) async throws -> CmxIrohEndpointSupervisor { - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: fixture.privateKey.rawRepresentation), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: [fixture.relayURL], - relays: [] - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: CmxIrohLibEndpointFactory(transportVerificationMode: .directOnly), - configuration: configuration - ) - _ = try await supervisor.activate() - return supervisor - } - - private func realServerEndpoint( - fixture: RegistryFixture, - ipAddress: String - ) async throws -> BoundServer { - var lastError: (any Error)? - for _ in 0 ..< 8 { - let port = try availableUDPPort(ipAddress: ipAddress) - do { - let endpoint = try await realServerEndpoint( - fixture: fixture, - ipAddress: ipAddress, - port: port - ) - return BoundServer(endpoint: endpoint, port: port) - } catch { - lastError = error - } - } - throw lastError ?? GateError.portAllocationFailed - } - - private func realServerEndpoint( - fixture: RegistryFixture, - ipAddress: String, - port: UInt16 - ) async throws -> any CmxIrohEndpoint { - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: fixture.acceptorSecretKey), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - bindPolicy: .required(CmxIrohBindAddress(ipAddress: ipAddress, port: port)), - managedRelayURLs: [fixture.relayURL], - relays: [] - ) - return try await CmxIrohLibEndpointFactory( - transportVerificationMode: .directOnly - ).bind(configuration: configuration) - } - - private func admissionController( - fixture: RegistryFixture, - broker: TestIrohRegistryBroker, - discovery: CmxIrohDiscoveryResponse, - now: Date - ) -> CmxIrohAdmissionController { - let onlineRegistry = CmxIrohOnlineAdmissionRegistry( - broker: broker, - keys: discovery.grantVerificationKeys, - acceptor: fixture.acceptor, - managedRelayURLs: [fixture.relayURL], - clock: FixedOnlineAdmissionClock(now: now) - ) - return CmxIrohAdmissionController( - acceptor: fixture.acceptor, - pairingEnabled: true, - offlineSessions: CmxIrohOfflinePairingSessions(pairingEnabled: true), - onlineRegistry: onlineRegistry, - now: { now } - ) - } - - private func connect( - clientSession: CmxIrohClientSession, - serverEndpoint: any CmxIrohEndpoint, - authorizer: CmxIrohAdmissionController - ) async throws -> CmxIrohServerSession { - try await withThrowingTaskGroup(of: CmxIrohServerSession.self) { group in - group.addTask { - async let serverSession = self.acceptAndAdmit( - endpoint: serverEndpoint, - authorizer: authorizer - ) - try await clientSession.connect() - return try await serverSession - } - group.addTask { - // This is a bounded release-gate deadline, not state polling. - try await ContinuousClock().sleep(for: .seconds(20)) - await clientSession.close() - await serverEndpoint.close() - throw GateError.connectionTimedOut - } - defer { group.cancelAll() } - let session: CmxIrohServerSession? = try await group.next() - guard let session else { throw GateError.connectionTimedOut } - return session - } - } - - private func acceptAndAdmit( - endpoint: any CmxIrohEndpoint, - authorizer: CmxIrohAdmissionController - ) async throws -> CmxIrohServerSession { - let connection = try #require(try await endpoint.accept()) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: authorizer - ) - _ = try await session.admit() - return session - } - - private func selectedPrivatePath( - _ session: CmxIrohClientSession - ) async throws -> Bool { - let paths = await session.observedSelectedPathChanges() - return try await withThrowingTaskGroup(of: Bool.self) { group in - group.addTask { - for await path in paths { - switch path { - case .privateNetwork: return true - case .direct, .relay: return false - case .unavailable: continue - } - } - return false - } - group.addTask { - // This is a bounded release-gate deadline, not state polling. - try await ContinuousClock().sleep(for: .seconds(10)) - throw GateError.connectionTimedOut - } - defer { group.cancelAll() } - let selectedPathIsPrivate: Bool? = try await group.next() - guard let selectedPathIsPrivate else { throw GateError.connectionTimedOut } - return selectedPathIsPrivate - } - } - - private func connectionAttemptOutcome( - endpoint: any CmxIrohEndpoint, - address: CmxIrohEndpointAddress - ) async -> ConnectionAttemptOutcome { - await withTaskGroup(of: ConnectionAttemptOutcome.self) { group in - group.addTask { - do { - let connection = try await endpoint.connect( - to: address, - alpn: CmxIrohProtocolConfiguration.cmuxMobileV1.alpn - ) - await connection.close(errorCode: 1, reason: "gate_unexpected_connection") - return .connected - } catch CmxIrohLibError.remoteIdentityMismatch { - return .remoteIdentityMismatch - } catch { - return .dialFailed - } - } - group.addTask { - // Iroh rejects an unknown cryptographic EndpointID before a - // connection exists, so a wrong live coordinate can remain pending. - // The same release-gate suite proves an authorized private - // coordinate carries bidirectional RPC, so this is not the only - // evidence used to assess network health. - try? await ContinuousClock().sleep(for: .seconds(5)) - return .observationElapsed - } - let outcome = await group.next() ?? .observationElapsed - group.cancelAll() - if outcome == .observationElapsed { - await endpoint.close() - } - return outcome - } - } - - private enum ConnectionAttemptOutcome: Equatable, Sendable { - case connected - case remoteIdentityMismatch - case dialFailed - case observationElapsed - } - - private func privateIPv4Address() throws -> String { - let addresses = try CmxIrohSystemLANInterfaceSnapshotProvider().interfaceAddresses() - guard let address = addresses.first(where: { - $0.family == .ipv4 - && CmxIrohIPAddressScope(socketAddress: "\($0.ipAddress):1").isPrivate - && (try? CmxIrohCustomPrivateAddress($0.ipAddress)) != nil - }) else { - throw GateError.noPrivateIPv4Interface - } - return address.ipAddress - } - - private func availableUDPPort(ipAddress: String) throws -> UInt16 { - let descriptor = Darwin.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) - guard descriptor >= 0 else { throw currentPOSIXError() } - defer { Darwin.close(descriptor) } - - var address = sockaddr_in() - address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size) - address.sin_family = sa_family_t(AF_INET) - guard ipAddress.withCString({ inet_pton(AF_INET, $0, &address.sin_addr) }) == 1 else { - throw GateError.noPrivateIPv4Interface - } - let bound = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) - } - } - guard bound == 0 else { throw currentPOSIXError() } - - var addressLength = socklen_t(MemoryLayout<sockaddr_in>.size) - let readAddress = withUnsafeMutablePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - Darwin.getsockname(descriptor, $0, &addressLength) - } - } - guard readAddress == 0 else { throw currentPOSIXError() } - return UInt16(bigEndian: address.sin_port) - } - - private func differentAvailableUDPPort( - ipAddress: String, - excluding excluded: UInt16 - ) throws -> UInt16 { - for _ in 0 ..< 8 { - let candidate = try availableUDPPort(ipAddress: ipAddress) - if candidate != excluded { return candidate } - } - throw GateError.portAllocationFailed - } - - private func privateNetworkProfile(id: String) throws -> CmxIrohNetworkProfileKey { - try CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: opaqueProfileID(id) - ) - } - - private func privateHint( - ipAddress: String, - port: UInt16, - profile: CmxIrohNetworkProfileKey, - now: Date - ) throws -> CmxIrohPathHint { - try CmxIrohPathHint( - kind: .directAddress, - value: "\(ipAddress):\(port)", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: now, - expiresAt: now.addingTimeInterval(60), - networkProfile: profile - ) - } - - private func peerIdentity(secretByte: UInt8) throws -> CmxIrohPeerIdentity { - let key = try Curve25519.Signing.PrivateKey( - rawRepresentation: Data(repeating: secretByte, count: 32) - ) - let endpointID = key.publicKey.rawRepresentation.map { - String(format: "%02x", $0) - }.joined() - return try CmxIrohPeerIdentity(endpointID: endpointID) - } - - private func currentPOSIXError() -> POSIXError { - POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistrationSignerTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistrationSignerTests.swift deleted file mode 100644 index b2bf1ce5..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistrationSignerTests.swift +++ /dev/null @@ -1,182 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite("Iroh registration signer") -struct CmxIrohRegistrationSignerTests { - @Test("signed transcript binds exact endpoint challenge and payload") - func signedTranscript() throws { - let secret = Data((0..<32).map(UInt8.init)) - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: secret) - let endpointID = privateKey.publicKey.rawRepresentation.hex - #expect(endpointID == "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8") - let identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: secret), - generation: 4 - ) - let now = Date(timeIntervalSince1970: 1_800_000_000) - let hint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://use1-1.relay.lawrence.cmux.iroh.link/", - source: .native, - privacyScope: .publicInternet, - observedAt: now, - expiresAt: now.addingTimeInterval(3_600) - ) - let payload = try CmxIrohRegistrationPayload( - deviceID: "123e4567-e89b-12d3-a456-426614174000", - appInstanceID: "123e4567-e89b-12d3-a456-426614174001", - tag: "stable", - platform: .ios, - displayName: "Phone", - endpointID: endpointID, - identityGeneration: 4, - pairingEnabled: false, - capabilities: ["rpc", "terminal.streams"], - pathHints: [hint], - directPorts: try CmxIrohDirectPorts(ipv4: 50_909, ipv6: 54_750), - now: now - ) - let signer = try CmxIrohRegistrationSigner( - identity: identity, - endpointID: endpointID - ) - let prepared = try signer.prepare(payload: payload) - let nonce = Data(repeating: 9, count: 32).base64URL - let challenge = CmxIrohChallengeResponse( - challengeID: "123e4567-e89b-12d3-a456-426614174002".uppercased(), - nonce: nonce, - expiresAt: "2027-01-15T08:05:00.000Z" - ) - - let request = try signer.sign(prepared: prepared, challenge: challenge) - let canonicalChallengeID = challenge.challengeID.lowercased() - let transcript = Data( - "cmux/iroh/device-registration/v1\n\(canonicalChallengeID)\n\(nonce)\n\(prepared.payloadSHA256)".utf8 - ) - let signature = try #require(Data(base64URL: request.signature)) - #expect(privateKey.publicKey.isValidSignature(signature, for: transcript)) - #expect(request.payload == prepared.encodedPayload) - #expect(request.challengeId == canonicalChallengeID) - #expect(prepared.challengeRequest.endpointId == endpointID) - #expect(prepared.challengeRequest.payloadSha256 == prepared.payloadSHA256) - - let payloadBytes = try #require(Data(base64URL: request.payload)) - #expect(Data(SHA256.hash(data: payloadBytes)).hex == prepared.payloadSHA256) - let payloadObject = try #require( - JSONSerialization.jsonObject(with: payloadBytes) as? [String: Any] - ) - #expect(payloadObject["endpointId"] as? String == endpointID) - #expect(payloadObject["endpointID"] == nil) - let pathHints = try #require(payloadObject["pathHints"] as? [[String: Any]]) - let encodedHint = try #require(pathHints.first) - #expect(encodedHint["observed_at"] is String) - #expect(encodedHint["expires_at"] is String) - let directPorts = try #require(payloadObject["directPorts"] as? [String: Int]) - #expect(directPorts == ["ipv4": 50_909, "ipv6": 54_750]) - } - - @Test("secret and declared endpoint must match") - func endpointMismatch() throws { - let identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 3, count: 32)), - generation: 1 - ) - - #expect(throws: CmxIrohRegistrationError.endpointIdentityMismatch) { - try CmxIrohRegistrationSigner( - identity: identity, - endpointID: String(repeating: "0", count: 64) - ) - } - } - - @Test("broker-incompatible stale hints fail before registration") - func staleHintsFail() throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let hint = try CmxIrohPathHint( - kind: .relayURL, - value: "https://use1-1.relay.lawrence.cmux.iroh.link/", - source: .native, - privacyScope: .publicInternet, - observedAt: now.addingTimeInterval(-7_200), - expiresAt: now.addingTimeInterval(60) - ) - - #expect(throws: CmxIrohRegistrationError.invalidPayload) { - try CmxIrohRegistrationPayload( - deviceID: "123e4567-e89b-12d3-a456-426614174000", - appInstanceID: "123e4567-e89b-12d3-a456-426614174001", - tag: "stable", - platform: .ios, - endpointID: String(repeating: "0", count: 64), - identityGeneration: 1, - pairingEnabled: false, - capabilities: [], - pathHints: [hint], - now: now - ) - } - } - - @Test("noncanonical challenge nonce is rejected") - func malformedChallengeFails() throws { - let secret = Data(repeating: 4, count: 32) - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: secret) - let endpointID = privateKey.publicKey.rawRepresentation.hex - let identity = try CmxIrohIdentityMaterial( - secretKey: CmxIrohSecretKey(bytes: secret), - generation: 1 - ) - let signer = try CmxIrohRegistrationSigner(identity: identity, endpointID: endpointID) - let now = Date(timeIntervalSince1970: 1_800_000_000) - let payload = try CmxIrohRegistrationPayload( - deviceID: "123e4567-e89b-12d3-a456-426614174000", - appInstanceID: "123e4567-e89b-12d3-a456-426614174001", - tag: "stable", - platform: .ios, - endpointID: endpointID, - identityGeneration: 1, - pairingEnabled: false, - capabilities: [], - pathHints: [], - now: now - ) - let prepared = try signer.prepare(payload: payload) - - #expect(throws: CmxIrohRegistrationError.invalidChallenge) { - try signer.sign( - prepared: prepared, - challenge: CmxIrohChallengeResponse( - challengeID: "123e4567-e89b-12d3-a456-426614174002", - nonce: "not+base64", - expiresAt: "2027-01-15T08:05:00.000Z" - ) - ) - } - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - init?(base64URL value: String) { - let padding = String(repeating: "=", count: (4 - value.count % 4) % 4) - self.init( - base64Encoded: value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") + padding - ) - } - - var hex: String { - map { String(format: "%02x", $0) }.joined() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift deleted file mode 100644 index 04ba1333..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderFallbackTests.swift +++ /dev/null @@ -1,316 +0,0 @@ -import CryptoKit -import CMUXMobileCore -import Foundation -import Testing - -@testable import CmuxIrohTransport - -extension CmxIrohRegistryContextProviderTests { - @Test - func authenticatedRemovalRevokesLANAuthorityBeforeFallbackCanBrowse() async throws { - let fixture = try RegistryFixture() - let recorder = TestLANFallbackRecorder(hints: []) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot(generation: 1, activeNetworkProfiles: []) - }, - lanFallback: { target, bindings, rendezvous in - await recorder.provide( - target: target, - bindings: bindings, - rendezvous: rendezvous - ) - }, - now: { fixture.now } - ) - let request = try fixture.request(hints: []) - let oldContext = try await provider.context(for: request) - - await broker.setDiscovery(try fixture.discovery( - targetHints: [], - includeTarget: false - )) - await #expect(throws: CmxIrohRegistryContextError.targetBindingUnavailable) { - try await provider.context(for: request) - } - - let fallback = try await provider.contextWithPrivateFallback( - for: request, - basedOn: oldContext - ) - #expect(fallback == oldContext) - #expect(await recorder.callCount() == 0) - } - - @Test - func policyEligibleFallbacksSurviveUnusableRegistryHintFlood() async throws { - let fixture = try RegistryFixture() - let profile = try CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: opaqueProfileID("tailnet-a") - ) - let managedRelay = try CmxIrohPathHint( - kind: .relayURL, - value: fixture.relayURL, - source: .native, - privacyScope: .publicInternet - ) - let tailscale = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.0.8:4242", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: fixture.now.addingTimeInterval(30 * 60), - networkProfile: profile - ) - let unusableRegistryHints = try (0 ..< CmxAttachEndpoint.maximumIrohPathHintCount).map { - try CmxIrohPathHint( - kind: .directAddress, - value: "10.0.0.\($0 + 1):4242", - source: .customVPN, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: fixture.now.addingTimeInterval(30 * 60), - networkProfile: CmxIrohNetworkProfileKey( - source: .customVPN, - profileID: opaqueProfileID("inactive-\($0)") - ) - ) - } - let discovery = try fixture.discovery( - targetHints: unusableRegistryHints, - targetDirectPorts: ["ipv4": 4_242] - ) - let response = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [response] - ) - let supervisor = try await fixture.activeSupervisor() - let pathSnapshot = CmxIrohNetworkPathSnapshot( - generation: 41, - activeNetworkProfiles: [profile] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: supervisor, - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { pathSnapshot }, - now: { fixture.now } - ) - let request = try fixture.request(hints: [managedRelay, tailscale]) - - let context = try await provider.context(for: request) - - #expect(context.dialPlan.publicPaths == [managedRelay]) - #expect(context.dialPlan.privateFallbackPaths == [tailscale]) - #expect(context.credential.kind == .pairGrant) - #expect(context.credential.pairGrantToken == response.grant) - let authorization = try #require(context.privateFallbackAuthorization) - #expect(authorization.networkPathSnapshot == pathSnapshot) - #expect(authorization.pathHints == [tailscale]) - #expect(authorization.admittedAt == fixture.now) - #expect(await broker.observedPairGrantRequests() == [ - .init( - initiatorBindingID: fixture.initiator.bindingID, - acceptorBindingID: fixture.acceptor.bindingID - ), - ]) - } - - @Test - func privateFallbackRevalidationRejectsChangedOrUnavailableNetworkState() async throws { - let fixture = try RegistryFixture() - let profile = try CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: opaqueProfileID("tailnet-a") - ) - let privateHint = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.0.8:4242", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: fixture.now.addingTimeInterval(30 * 60), - networkProfile: profile - ) - let response = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: [privateHint]), - pairGrantResponses: [response] - ) - let pathState = TestNetworkPathState( - snapshot: CmxIrohNetworkPathSnapshot( - generation: 9, - activeNetworkProfiles: [profile] - ) - ) - let clock = TestRegistryClock(fixture.now) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { try await pathState.currentSnapshot() }, - now: { clock.value() } - ) - let context = try await provider.context(for: fixture.request(hints: [])) - let authorization = try #require(context.privateFallbackAuthorization) - - await pathState.setSnapshot(CmxIrohNetworkPathSnapshot( - generation: 10, - activeNetworkProfiles: [profile] - )) - await #expect(throws: CmxIrohPrivateFallbackValidationError.generationChanged) { - try await provider.validatePrivateFallback(authorization) - } - - await pathState.setSnapshot(CmxIrohNetworkPathSnapshot( - generation: 9, - activeNetworkProfiles: [] - )) - await #expect(throws: CmxIrohPrivateFallbackValidationError.profileUnavailable) { - try await provider.validatePrivateFallback(authorization) - } - - await pathState.setSnapshot(CmxIrohNetworkPathSnapshot( - generation: 9, - activeNetworkProfiles: [profile] - )) - clock.set(fixture.now.addingTimeInterval(30 * 60 + 1)) - await #expect(throws: CmxIrohPrivateFallbackValidationError.hintExpiredOrInvalid) { - try await provider.validatePrivateFallback(authorization) - } - - clock.set(fixture.now) - await pathState.setUnavailable() - await #expect(throws: CmxIrohPrivateFallbackValidationError.unavailable) { - try await provider.validatePrivateFallback(authorization) - } - } - - @Test - func generationlessProfileSourceCannotAdmitPrivateFallback() async throws { - let fixture = try RegistryFixture() - let profile = try CmxIrohNetworkProfileKey( - source: .tailscale, - profileID: opaqueProfileID("tailnet-a") - ) - let privateHint = try CmxIrohPathHint( - kind: .directAddress, - value: "100.64.0.8:4242", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: fixture.now.addingTimeInterval(30 * 60), - networkProfile: profile - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: [privateHint]), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [profile] }, - now: { fixture.now } - ) - - let context = try await provider.context(for: fixture.request(hints: [])) - - #expect(context.dialPlan.privateFallbackPaths.isEmpty) - #expect(context.privateFallbackAuthorization == nil) - } - - @Test - func signedExpiryDrivesCacheRefreshBoundary() async throws { - let fixture = try RegistryFixture() - let clock = TestRegistryClock(fixture.now) - let refreshedAt = fixture.now.addingTimeInterval(4 * 24 * 60 * 60 + 1) - let refreshedSeconds = Int64(refreshedAt.timeIntervalSince1970) - let first = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let second = try fixture.pairGrantResponse( - issuedAt: refreshedSeconds, - expiresAt: refreshedSeconds + 7 * 24 * 60 * 60 - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [first, second] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { clock.value() } - ) - let request = try fixture.request(hints: []) - - #expect(try await provider.context(for: request).credential.pairGrantToken == first.grant) - #expect(try await provider.context(for: request).credential.pairGrantToken == first.grant) - #expect(await broker.pairGrantRequestCount() == 1) - - clock.set(refreshedAt) - #expect(try await provider.context(for: request).credential.pairGrantToken == second.grant) - #expect(await broker.pairGrantRequestCount() == 2) - } - - @Test - func responseExpiryMustMatchSignedGrantExpiry() async throws { - let fixture = try RegistryFixture() - let signedExpiry = fixture.nowSeconds + 7 * 24 * 60 * 60 - let token = try fixture.pairGrant( - issuedAt: fixture.nowSeconds, - expiresAt: signedExpiry - ) - let inconsistent = try fixture.pairGrantResponse( - token: token, - expiresAt: Date(timeIntervalSince1970: TimeInterval(signedExpiry + 60)) - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [inconsistent] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { fixture.now } - ) - - await #expect(throws: CmxIrohRegistryContextError.invalidGrantExpiry) { - try await provider.context(for: fixture.request(hints: [])) - } - } - -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift deleted file mode 100644 index 8eccc8fc..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift +++ /dev/null @@ -1,344 +0,0 @@ -import CryptoKit -import CMUXMobileCore -import Foundation -import Testing - -@testable import CmuxIrohTransport - -extension CmxIrohRegistryContextProviderTests { - @Test - func discoveryMustPublishTheExactConfiguredRelayFleet() async throws { - let fixture = try RegistryFixture() - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - relayFleet: [fixture.relayURL, "https://unexpected.example.com/"] - ), - pairGrantResponses: [] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { fixture.now } - ) - - await #expect(throws: CmxIrohRegistryContextError.relayFleetMismatch) { - try await provider.context(for: fixture.request(hints: [])) - } - #expect(await broker.pairGrantRequestCount() == 0) - } - - @Test - func routeEndpointCannotSubstituteAnotherDeviceBinding() async throws { - let fixture = try RegistryFixture() - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { fixture.now } - ) - let substitutedRequest = try fixture.request( - hints: [], - expectedPeerDeviceID: "123e4567-e89b-42d3-a456-426614174099" - ) - - await #expect(throws: CmxIrohRegistryContextError.targetDeviceMismatch) { - try await provider.context(for: substitutedRequest) - } - #expect(await broker.pairGrantRequestCount() == 0) - } - - @Test - func legacyUppercaseUUIDMatchesCanonicalBrokerDeviceID() async throws { - let fixture = try RegistryFixture() - let response = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [response] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { fixture.now } - ) - - let context = try await provider.context(for: fixture.request( - hints: [], - expectedPeerDeviceID: fixture.acceptor.deviceID.uppercased() - )) - - #expect(context.credential.pairGrantToken == response.grant) - #expect(await broker.pairGrantRequestCount() == 1) - } - - @Test - func localEndpointIDCannotSubstituteAnotherAppInstanceBinding() async throws { - let fixture = try RegistryFixture() - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - localAppInstanceID: "123e4567-e89b-42d3-a456-426614174099" - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { fixture.now } - ) - - await #expect(throws: CmxIrohRegistryContextError.localBindingUnavailable) { - try await provider.context(for: fixture.request(hints: [])) - } - #expect(await broker.pairGrantRequestCount() == 0) - } - - @Test - func discoveryConnectivityUsesOnlyAReverifiedOfflinePolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let grant = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: grant, - for: expectation, - now: fixture.now - ) - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [], - discoveryError: CmxIrohTrustBrokerClientError.connectivity - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - offlinePolicy: try CmxIrohClientOfflinePolicyContext( - cache: cache, - expectation: expectation, - localBinding: discovery.bindings[0] - ), - now: { fixture.now } - ) - - let context = try await provider.context(for: fixture.request(hints: [])) - - #expect(context.credential.pairGrantToken == grant.grant) - #expect(await store.readCount() > 0) - } - - @Test - func grantConnectivityUsesCacheOnlyForFreshlyConfirmedTuples() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let grant = try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ) - let cache = CmxIrohClientOfflinePolicyCache( - secureStore: TestSecureCredentialStore() - ) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: grant, - for: expectation, - now: fixture.now - ) - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [], - pairGrantError: CmxIrohTrustBrokerClientError.connectivity - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - offlinePolicy: try CmxIrohClientOfflinePolicyContext( - cache: cache, - expectation: expectation, - localBinding: discovery.bindings[0] - ), - now: { fixture.now } - ) - - let context = try await provider.context(for: fixture.request(hints: [])) - - #expect(context.credential.pairGrantToken == grant.grant) - #expect(await broker.pairGrantRequestCount() == 1) - } - - @Test - func authenticatedBrokerFailuresNeverConsultOfflinePolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ), - for: expectation, - now: fixture.now - ) - let readsBeforeDial = await store.readCount() - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [], - discoveryError: CmxIrohTrustBrokerClientError.rejected( - statusCode: 401, - code: "unauthorized" - ) - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - offlinePolicy: try CmxIrohClientOfflinePolicyContext( - cache: cache, - expectation: expectation, - localBinding: discovery.bindings[0] - ), - now: { fixture.now } - ) - - await #expect(throws: CmxIrohTrustBrokerClientError.rejected( - statusCode: 401, - code: "unauthorized" - )) { - try await provider.context(for: fixture.request(hints: [])) - } - #expect(await store.readCount() == readsBeforeDial) - } - - @Test - func tlsAndDecodeFailuresNeverConsultOfflinePolicy() async throws { - let fixture = try RegistryFixture() - let discovery = try fixture.discovery(targetHints: []) - let store = TestSecureCredentialStore() - let cache = CmxIrohClientOfflinePolicyCache(secureStore: store) - let expectation = try fixture.offlineExpectation() - try await cache.save( - localBinding: discovery.bindings[0], - targetBinding: discovery.bindings[1], - discovery: discovery, - pairGrant: try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - ), - for: expectation, - now: fixture.now - ) - for error in [ - TestRegistryBrokerFailure.tls, - TestRegistryBrokerFailure.decode, - ] { - let readsBeforeDial = await store.readCount() - let broker = TestIrohRegistryBroker( - discovery: discovery, - pairGrantResponses: [], - discoveryError: error.error - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - offlinePolicy: try CmxIrohClientOfflinePolicyContext( - cache: cache, - expectation: expectation, - localBinding: discovery.bindings[0] - ), - now: { fixture.now } - ) - - do { - _ = try await provider.context(for: fixture.request(hints: [])) - Issue.record("Expected \(error) to fail closed") - } catch { - #expect(await store.readCount() == readsBeforeDial) - } - } - } - - @Test - func pairGrantRateLimitSuppressesBrokerRequestsUntilRetryDeadline() async throws { - let fixture = try RegistryFixture() - let clock = TestRegistryClock(fixture.now) - let rateLimit = CmxIrohTrustBrokerClientError.rateLimited( - code: "pair_grant_hour_quota", - retryAfterSeconds: 120 - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [], - pairGrantError: rateLimit - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - activeNetworkProfiles: { [] }, - now: { clock.value() } - ) - let request = try fixture.request(hints: []) - - await #expect(throws: rateLimit) { - try await provider.context(for: request) - } - await #expect(throws: rateLimit) { - try await provider.context(for: request) - } - #expect(await broker.pairGrantRequestCount() == 1) - - clock.set(fixture.now.addingTimeInterval(121)) - await #expect(throws: rateLimit) { - try await provider.context(for: request) - } - #expect(await broker.pairGrantRequestCount() == 2) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift deleted file mode 100644 index b2c9bb69..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift +++ /dev/null @@ -1,639 +0,0 @@ -@preconcurrency import CryptoKit -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -func opaqueProfileID(_ label: String) -> String { - SHA256.hash(data: Data(label.utf8)).map { String(format: "%02x", $0) }.joined() -} - -@Suite -struct CmxIrohRegistryContextProviderTests { - @Test - func authenticatedDirectPortsReplaceLegacyTailscaleTCPPorts() async throws { - let fixture = try RegistryFixture() - let profile = CmxIrohNetworkProfileKey.activeTailscaleTunnel - let expiresAt = fixture.now.addingTimeInterval(60) - let ipv4 = try CmxIrohPathHint( - kind: .directAddress, - value: "100.82.214.112:53646", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: expiresAt, - networkProfile: profile - ) - let ipv6 = try CmxIrohPathHint( - kind: .directAddress, - value: "[fd7a:115c:a1e0::4b36:d670]:53646", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: expiresAt, - networkProfile: profile - ) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - targetDirectPorts: ["ipv4": 50_909, "ipv6": 54_750] - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 1, - activeNetworkProfiles: [profile] - ) - }, - now: { fixture.now } - ) - - let context = try await provider.context( - for: fixture.request(hints: [ipv4, ipv6]) - ) - - #expect(context.dialPlan.privateFallbackPaths.map(\.value) == [ - "100.82.214.112:50909", - "[fd7a:115c:a1e0::4b36:d670]:54750", - ]) - } - - @Test - func missingStaleOrWrongFamilyPortsCannotAuthorizePrivatePortGuessing() async throws { - let fixture = try RegistryFixture() - let profile = CmxIrohNetworkProfileKey.activeTailscaleTunnel - let managedRelay = try CmxIrohPathHint( - kind: .relayURL, - value: fixture.relayURL, - source: .native, - privacyScope: .publicInternet - ) - let tailscale = try CmxIrohPathHint( - kind: .directAddress, - value: "100.82.214.112:53646", - source: .tailscale, - privacyScope: .privateNetwork, - observedAt: fixture.now, - expiresAt: fixture.now.addingTimeInterval(60), - networkProfile: profile - ) - let stale = fixture.now.addingTimeInterval( - -(CmxIrohPathHint.maximumPrivateHintTTL + 1) - ) - let cases: [(String, [String: Int]?, Date?)] = [ - ("missing", nil, nil), - ("wrong family", ["ipv6": 54_750], nil), - ("stale", ["ipv4": 50_909], stale), - ] - - for (name, directPorts, lastSeenAt) in cases { - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery( - targetHints: [], - targetDirectPorts: directPorts, - targetLastSeenAt: lastSeenAt - ), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 1, - activeNetworkProfiles: [profile] - ) - }, - now: { fixture.now } - ) - - let context = try await provider.context( - for: fixture.request(hints: [managedRelay, tailscale]) - ) - - #expect(context.dialPlan.publicPaths == [managedRelay], Comment(rawValue: name)) - #expect(context.dialPlan.privateFallbackPaths.isEmpty, Comment(rawValue: name)) - #expect(context.privateFallbackAuthorization == nil, Comment(rawValue: name)) - } - } - - @Test - func bonjourFallbackAcceptsLegacyUppercaseDeviceUUID() async throws { - let fixture = try RegistryFixture() - let relay = try CmxIrohPathHint( - kind: .relayURL, - value: fixture.relayURL, - source: .native, - privacyScope: .publicInternet - ) - let profile = try CmxIrohNetworkProfileKey( - source: .lan, - profileID: opaqueProfileID("bonjour-profile") - ) - let lanHint = try CmxIrohPathHint( - kind: .directAddress, - value: "192.168.1.10:50906", - source: .lan, - privacyScope: .localNetwork, - observedAt: fixture.now, - expiresAt: fixture.now.addingTimeInterval(60), - networkProfile: profile - ) - let recorder = TestLANFallbackRecorder(hints: [lanHint]) - let broker = TestIrohRegistryBroker( - discovery: try fixture.discovery(targetHints: []), - pairGrantResponses: [try fixture.pairGrantResponse( - issuedAt: fixture.nowSeconds, - expiresAt: fixture.nowSeconds + 7 * 24 * 60 * 60 - )] - ) - let provider = CmxIrohRegistryContextProvider( - supervisor: try await fixture.activeSupervisor(), - broker: broker, - localBindingExpectation: try fixture.localExpectation(), - managedRelayURLs: [fixture.relayURL], - networkPathSnapshot: { - CmxIrohNetworkPathSnapshot( - generation: 23, - activeNetworkProfiles: [profile] - ) - }, - lanFallback: { target, bindings, rendezvous in - await recorder.provide( - target: target, - bindings: bindings, - rendezvous: rendezvous - ) - }, - now: { fixture.now } - ) - let request = try fixture.request( - hints: [relay], - expectedPeerDeviceID: fixture.acceptor.deviceID.uppercased() - ) - let publicContext = try await provider.context(for: request) - - #expect(await recorder.callCount() == 0) - #expect(publicContext.dialPlan.publicPaths == [relay]) - #expect(publicContext.dialPlan.privateFallbackPaths.isEmpty) - - let fallbackContext = try await provider.contextWithPrivateFallback( - for: request, - basedOn: publicContext - ) - - #expect(await recorder.callCount() == 1) - #expect(await recorder.lastTarget() == fixture.acceptor.endpointID) - #expect(await recorder.lastBindingCount() == 2) - #expect(fallbackContext.dialPlan.publicPaths == [relay]) - #expect(fallbackContext.dialPlan.privateFallbackPaths == [lanHint]) - let authorization = try #require(fallbackContext.privateFallbackAuthorization) - #expect(authorization.networkPathSnapshot.generation == 23) - #expect(authorization.pathHints == [lanHint]) - } -} -enum TestRegistryBrokerFailure: CaseIterable, CustomStringConvertible { - case tls - case decode - - var error: any Error { - switch self { - case .tls: URLError(.serverCertificateUntrusted) - case .decode: CmxIrohTrustBrokerClientError.invalidResponse - } - } - - var description: String { - switch self { - case .tls: "TLS failure" - case .decode: "decode failure" - } - } -} - -actor TestIrohRegistryBroker: CmxIrohRegistryServing { - struct PairGrantRequest: Equatable, Sendable { - let initiatorBindingID: String - let acceptorBindingID: String - } - - private var discoveryResponse: CmxIrohDiscoveryResponse - private var responses: [CmxIrohPairGrantResponse] - private var pairGrantRequests: [PairGrantRequest] = [] - private let discoveryError: (any Error)? - private let pairGrantError: (any Error)? - - init( - discovery: CmxIrohDiscoveryResponse, - pairGrantResponses: [CmxIrohPairGrantResponse], - discoveryError: (any Error)? = nil, - pairGrantError: (any Error)? = nil - ) { - discoveryResponse = discovery - responses = pairGrantResponses - self.discoveryError = discoveryError - self.pairGrantError = pairGrantError - } - - func discover() throws -> CmxIrohDiscoveryResponse { - if let discoveryError { throw discoveryError } - return discoveryResponse - } - - func setDiscovery(_ discovery: CmxIrohDiscoveryResponse) { - discoveryResponse = discovery - } - - func issuePairGrant( - initiatorBindingID: String, - acceptorBindingID: String - ) throws -> CmxIrohPairGrantResponse { - pairGrantRequests.append(.init( - initiatorBindingID: initiatorBindingID, - acceptorBindingID: acceptorBindingID - )) - if let pairGrantError { throw pairGrantError } - guard !responses.isEmpty else { throw TestRegistryError.noGrantResponse } - return responses.removeFirst() - } - - func observedPairGrantRequests() -> [PairGrantRequest] { - pairGrantRequests - } - - func pairGrantRequestCount() -> Int { - pairGrantRequests.count - } -} - -actor TestLANFallbackRecorder { - private let hints: [CmxIrohPathHint] - private var targets: [CmxIrohPeerIdentity] = [] - private var bindingCounts: [Int] = [] - - init(hints: [CmxIrohPathHint]) { - self.hints = hints - } - - func provide( - target: CmxIrohBrokerBindingMetadata, - bindings: [CmxIrohBrokerBindingMetadata], - rendezvous _: CmxIrohLANRendezvous - ) -> [CmxIrohPathHint] { - targets.append(target.endpointID) - bindingCounts.append(bindings.count) - return hints - } - - func callCount() -> Int { targets.count } - func lastTarget() -> CmxIrohPeerIdentity? { targets.last } - func lastBindingCount() -> Int? { bindingCounts.last } -} - -final class TestRegistryClock: @unchecked Sendable { - private let lock = NSLock() - private var date: Date - - init(_ date: Date) { - self.date = date - } - - func value() -> Date { - lock.lock() - defer { lock.unlock() } - return date - } - - func set(_ date: Date) { - lock.lock() - self.date = date - lock.unlock() - } -} - -enum TestRegistryError: Error { - case noGrantResponse -} - -actor TestNetworkPathState { - private var snapshot: CmxIrohNetworkPathSnapshot? - - init(snapshot: CmxIrohNetworkPathSnapshot) { - self.snapshot = snapshot - } - - func currentSnapshot() throws -> CmxIrohNetworkPathSnapshot { - guard let snapshot else { throw TestNetworkPathStateError.unavailable } - return snapshot - } - - func setSnapshot(_ snapshot: CmxIrohNetworkPathSnapshot) { - self.snapshot = snapshot - } - - func setUnavailable() { - snapshot = nil - } -} - -enum TestNetworkPathStateError: Error { - case unavailable -} - -struct RegistryFixture: Sendable { - let privateKey: Curve25519.Signing.PrivateKey - let acceptorSecretKey: Data - let key: CmxIrohGrantVerificationKey - let initiator: CmxIrohGrantPeer - let acceptor: CmxIrohGrantPeer - let now: Date - let nowSeconds: Int64 - let relayURL = "https://use1-1.relay.lawrence.cmux.iroh.link/" - - init( - now: Date = Date(timeIntervalSince1970: 1_800_000_000), - initiatorSecretKey: Data = Data((0 ..< 32).map(UInt8.init)), - acceptorSecretKey: Data = Data(repeating: 9, count: 32) - ) throws { - self.now = now - self.acceptorSecretKey = acceptorSecretKey - nowSeconds = Int64(now.timeIntervalSince1970.rounded(.down)) - privateKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: initiatorSecretKey - ) - let targetKey = try Curve25519.Signing.PrivateKey( - rawRepresentation: acceptorSecretKey - ) - initiator = CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174001", - deviceID: "123e4567-e89b-42d3-a456-426614174002", - tag: "ios", - platform: .ios, - endpointID: try CmxIrohPeerIdentity( - endpointID: privateKey.publicKey.rawRepresentation.registryHex - ), - identityGeneration: 1 - ) - acceptor = CmxIrohGrantPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174003", - deviceID: "123e4567-e89b-42d3-a456-426614174004", - tag: "mac", - platform: .mac, - endpointID: try CmxIrohPeerIdentity( - endpointID: targetKey.publicKey.rawRepresentation.registryHex - ), - identityGeneration: 2 - ) - let prefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, - 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]) - key = CmxIrohGrantVerificationKey( - kid: "current", - alg: "EdDSA", - spkiDerBase64: (prefix + privateKey.publicKey.rawRepresentation).base64EncodedString() - ) - } - - func activeSupervisor() async throws -> CmxIrohEndpointSupervisor { - let endpoint = TestIrohEndpoint(identity: initiator.endpointID) - let factory = TestIrohEndpointFactory(endpoints: [endpoint]) - let configuration = try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 4, count: 32)), - alpns: [Data("cmux/mobile/1".utf8)], - managedRelayURLs: [relayURL], - relays: [] - ) - let supervisor = CmxIrohEndpointSupervisor( - factory: factory, - configuration: configuration - ) - _ = try await supervisor.activate() - return supervisor - } - - func localExpectation() throws -> CmxIrohLocalBindingExpectation { - try CmxIrohLocalBindingExpectation( - deviceID: initiator.deviceID, - appInstanceID: "123e4567-e89b-42d3-a456-426614174005", - tag: initiator.tag, - platform: initiator.platform, - endpointID: initiator.endpointID, - identityGeneration: initiator.identityGeneration, - pairingEnabled: false, - capabilities: ["multistream-v1"] - ) - } - - func offlineExpectation( - accountID: String = "account-a", - localExpectation: CmxIrohLocalBindingExpectation? = nil, - managedRelayURLs: Set<String>? = nil - ) throws -> CmxIrohClientOfflinePolicyExpectation { - try CmxIrohClientOfflinePolicyExpectation( - accountID: accountID, - localBindingExpectation: localExpectation ?? self.localExpectation(), - managedRelayURLs: managedRelayURLs ?? [relayURL] - ) - } - - func route(hints: [CmxIrohPathHint]) throws -> CmxAttachRoute { - try CmxAttachRoute( - id: "iroh-primary", - kind: .iroh, - endpoint: .peer(identity: acceptor.endpointID, pathHints: hints) - ) - } - - func request( - hints: [CmxIrohPathHint], - expectedPeerDeviceID: String? = nil - ) throws -> CmxByteTransportRequest { - CmxByteTransportRequest( - route: try route(hints: hints), - expectedPeerDeviceID: expectedPeerDeviceID ?? acceptor.deviceID, - authorizationMode: .transportAdmission - ) - } - - func discovery( - targetHints: [CmxIrohPathHint], - targetDirectPorts: [String: Int]? = nil, - targetLastSeenAt: Date? = nil, - relayFleet: [String]? = nil, - localAppInstanceID: String = "123e4567-e89b-42d3-a456-426614174005", - targetDeviceID: String? = nil, - includeTarget: Bool = true - ) throws -> CmxIrohDiscoveryResponse { - var bindings: [[String: Any]] = [ - try bindingObject( - peer: initiator, - appInstanceID: localAppInstanceID, - pairingEnabled: false, - hints: [] - ), - ] - if includeTarget { - var target = try bindingObject( - peer: CmxIrohGrantPeer( - bindingID: acceptor.bindingID, - deviceID: targetDeviceID ?? acceptor.deviceID, - tag: acceptor.tag, - platform: acceptor.platform, - endpointID: acceptor.endpointID, - identityGeneration: acceptor.identityGeneration - ), - appInstanceID: "123e4567-e89b-42d3-a456-426614174006", - pairingEnabled: true, - hints: targetHints - ) - if let targetDirectPorts { - target["direct_ports"] = targetDirectPorts - } - if let targetLastSeenAt { - target["last_seen_at"] = ISO8601DateFormatter().string( - from: targetLastSeenAt - ) - } - bindings.append(target) - } - let object: [String: Any] = [ - "route_contract_version": 1, - "bindings": bindings, - "relay_fleet": relayFleet ?? [relayURL], - "lan_rendezvous": [ - "generation": 1, - "key": Data(repeating: 7, count: 32).registryBase64URL, - ], - "grant_verification_keys": [ - "version": 1, - "current_kid": key.kid, - "keys": [[ - "kid": key.kid, - "alg": key.alg, - "spki_der_base64": key.spkiDerBase64, - ]], - ], - ] - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode( - CmxIrohDiscoveryResponse.self, - from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - ) - } - - func pairGrantResponse( - issuedAt: Int64, - expiresAt: Int64 - ) throws -> CmxIrohPairGrantResponse { - try pairGrantResponse( - token: pairGrant(issuedAt: issuedAt, expiresAt: expiresAt), - expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAt)) - ) - } - - func pairGrantResponse( - token: String, - expiresAt: Date - ) throws -> CmxIrohPairGrantResponse { - let object = [ - "grant": token, - "expires_at": ISO8601DateFormatter().string(from: expiresAt), - ] - return try JSONDecoder().decode( - CmxIrohPairGrantResponse.self, - from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - ) - } - - func pairGrant(issuedAt: Int64, expiresAt: Int64) throws -> String { - let claims: [String: Any] = [ - "jti": UUID().uuidString.lowercased(), - "iat": issuedAt, - "nbf": issuedAt - 5, - "exp": expiresAt, - "alpn": "cmux/mobile/1", - "scope": "cmux.mobile.attach", - "initiator": peerObject(initiator), - "acceptor": peerObject(acceptor), - ] - let header = try JSONSerialization.data( - withJSONObject: ["alg": "EdDSA", "typ": "cmux-pair-grant+jwt", "kid": key.kid], - options: [.sortedKeys] - ).registryBase64URL - let payload = try JSONSerialization.data( - withJSONObject: claims, - options: [.sortedKeys] - ).registryBase64URL - let signingInput = "\(header).\(payload)" - let signature = try privateKey.signature( - for: Data(signingInput.utf8) - ).registryBase64URL - return "\(signingInput).\(signature)" - } - - private func bindingObject( - peer: CmxIrohGrantPeer, - appInstanceID: String, - pairingEnabled: Bool, - hints: [CmxIrohPathHint] - ) throws -> [String: Any] { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let hintObjects = try hints.map { - try JSONSerialization.jsonObject(with: encoder.encode($0)) - } - return [ - "binding_id": peer.bindingID, - "device_id": peer.deviceID, - "app_instance_id": appInstanceID, - "tag": peer.tag, - "platform": peer.platform.rawValue, - "endpoint_id": peer.endpointID.endpointID, - "identity_generation": peer.identityGeneration, - "pairing_enabled": pairingEnabled, - "capabilities": ["multistream-v1"], - "path_hints": hintObjects, - "last_seen_at": ISO8601DateFormatter().string(from: now), - ] - } - - private func peerObject(_ peer: CmxIrohGrantPeer) -> [String: Any] { - [ - "bindingId": peer.bindingID, - "deviceId": peer.deviceID, - "tag": peer.tag, - "platform": peer.platform.rawValue, - "endpointId": peer.endpointID.endpointID, - "identityGeneration": peer.identityGeneration, - ] - } -} - -private extension Data { - var registryBase64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - var registryHex: String { - map { String(format: "%02x", $0) }.joined() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift deleted file mode 100644 index 2f23fb7a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests+Refresh.swift +++ /dev/null @@ -1,234 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohRelayCredentialCoordinatorTests { - @Test - func concurrentForegroundCatchUpSharesOneBrokerMint() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let expiry = fixture.now.addingTimeInterval(5 * 60) - let refresh = expiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(15 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let gate = TestRelayIssueGate() - let broker = TestRelayTokenBroker( - steps: [.response(try fixture.response( - tokens: ["ghi234", "jkl567"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - ))], - issueHook: { count in - if count == 1 { await gate.park() } - } - ) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def567"], - refreshAfter: refresh, - expiresAt: expiry - ) - ) - clock.setNowWithoutResuming(expiry.addingTimeInterval(1)) - - let first = Task { try await coordinator.refreshIfNeeded() } - await gate.waitUntilParked() - let second = Task { try await coordinator.refreshIfNeeded() } - for _ in 0 ..< 20 { await Task.yield() } - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - - await gate.release() - try await first.value - try await second.value - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - await coordinator.deactivate() - } - - @Test - func foregroundCatchUpRefreshesCredentialAfterSuspensionPastDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let initialExpiry = fixture.now.addingTimeInterval(5 * 60) - let initialRefresh = initialExpiry.addingTimeInterval(-60) - let replacementExpiry = fixture.now.addingTimeInterval(15 * 60) - let replacementRefresh = replacementExpiry.addingTimeInterval(-60) - let broker = TestRelayTokenBroker(steps: [ - .response(try fixture.response( - tokens: ["ghi234", "jkl567"], - refreshAfter: replacementRefresh, - expiresAt: replacementExpiry - )), - ]) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - tokens: ["abc234", "def567"], - refreshAfter: initialRefresh, - expiresAt: initialExpiry - ) - ) - clock.setNowWithoutResuming(initialExpiry.addingTimeInterval(1)) - - try await coordinator.refreshIfNeeded() - - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 2) - #expect(await endpoint.observedRelayUpdates().last?.map(\.token) == [ - "ghi234", - "jkl567", - ]) - #expect(await coordinator.credentialExpiresAt() == replacementExpiry) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func foregroundCatchUpDoesNotMintBeforeRefreshDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: []) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - - try await coordinator.refreshIfNeeded() - - #expect(await broker.observedEndpointIDs().isEmpty) - #expect(await endpoint.observedRelayUpdates().count == 1) - await coordinator.deactivate() - } - - @Test - func refreshFailureRetriesBeforeInstalledCredentialSafetyDeadline() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let expiresAt = fixture.now.addingTimeInterval(5 * 60) - let refreshAfter = expiresAt.addingTimeInterval(-60) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response( - refreshAfter: refreshAfter, - expiresAt: expiresAt - ) - ) - #expect(await clockEvents.next() == .sleep(refreshAfter)) - - clock.advance(to: refreshAfter) - - guard case let .sleep(retryDeadline) = await clockEvents.next() else { - Issue.record("Expected a relay retry before credential expiry") - return - } - #expect(retryDeadline == expiresAt.addingTimeInterval(-30)) - #expect(retryDeadline < expiresAt) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - await coordinator.deactivate() - } - - @Test - func mismatchedBootstrapFleetNeverMutatesEndpoint() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.failure]), - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - let incomplete = try fixture.response(relayURLs: [fixture.relayURLs[0]]) - - await #expect( - throws: CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch - ) { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: incomplete - ) - } - await coordinator.deactivate() - - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - } -} - -private actor TestRelayIssueGate { - private var isParked = false - private var parkContinuation: CheckedContinuation<Void, Never>? - private var parkedWaiters: [CheckedContinuation<Void, Never>] = [] - - func park() async { - isParked = true - let waiters = parkedWaiters - parkedWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { parkContinuation = $0 } - } - - func waitUntilParked() async { - guard !isParked else { return } - await withCheckedContinuation { parkedWaiters.append($0) } - } - - func release() { - parkContinuation?.resume() - parkContinuation = nil - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift deleted file mode 100644 index 2fde7551..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +++ /dev/null @@ -1,494 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRelayCredentialCoordinatorTests { - @Test - func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: []) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let response = try fixture.response() - let installs = TestRelayCredentialInstallRecorder() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - credentialDidInstall: { response in - await installs.record(response) - } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: response - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay refresh sleep") - return - } - #expect(deadline == fixture.refreshAfter) - let updates = await endpoint.observedRelayUpdates() - #expect(updates.count == 1) - #expect(updates[0].map(\.url) == fixture.relayURLs) - #expect(await coordinator.credentialExpiresAt() == fixture.expiresAt) - await installs.waitForCount(1) - #expect(await installs.values() == [response]) - #expect(await broker.observedEndpointIDs().isEmpty) - await coordinator.deactivate() - #expect(await clockEvents.next() == .cancelled) - } - - @Test - func stalledCredentialPersistenceDoesNotBlockRefreshScheduling() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let persistence = TestRelayCredentialPersistenceGate() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 }, - credentialDidInstall: { response in - await persistence.persist(response) - } - ) - - let activation = Task { - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - } - await persistence.waitUntilStarted() - for _ in 0 ..< 20 { await Task.yield() } - - #expect(clock.observedSleepDeadlines() == [fixture.refreshAfter]) - #expect(await endpoint.observedRelayUpdates().count == 1) - - await persistence.resume() - try await activation.value - await coordinator.deactivate() - } - - @Test - func bootstrapKeepsEachTokenAssociatedWithItsSignedRelayURL() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - clock: TestRelayClock(now: fixture.now), - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response(tokens: ["abc234", "def567"]) - ) - - let updates = await endpoint.observedRelayUpdates() - #expect(updates.count == 1) - #expect(updates[0].map(\.url) == fixture.relayURLs) - #expect(updates[0].map(\.token) == ["abc234", "def567"]) - await coordinator.deactivate() - } - - @Test - func selectedManagedSubsetInstallsOnlyChosenRelayAfterFullFleetValidation() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - let selectedURL = fixture.relayURLs[1] - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: []), - managedRelayURLs: Set(fixture.relayURLs), - selectedRelayURLs: [selectedURL], - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity, - bootstrap: try fixture.response() - ) - - let profiles = await endpoint.observedRelayProfileUpdates() - #expect(profiles.count == 1) - #expect(profiles[0].allowedRelayURLs == [selectedURL]) - #expect(profiles[0].managedRelays.map(\.url) == [selectedURL]) - #expect(await endpoint.observedRelayUpdates().isEmpty) - await coordinator.deactivate() - } - - @Test - func missingBootstrapRefreshesImmediatelyAndInstallsWithoutRebinding() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.response(try fixture.response())]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay refresh sleep") - return - } - #expect(deadline == fixture.refreshAfter) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().count == 1) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func transientMintFailureKeepsEndpointAliveAndBacksOff() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let broker = TestRelayTokenBroker(steps: [.failure]) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: broker, - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - guard case let .sleep(deadline) = await clockEvents.next() else { - Issue.record("Expected the relay retry sleep") - return - } - #expect(deadline == fixture.now.addingTimeInterval(30)) - #expect(await broker.observedEndpointIDs() == [fixture.identity]) - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(try await supervisor.activeEndpoint().identity() == fixture.identity) - await coordinator.deactivate() - } - - @Test - func rateLimitRetryNeverPrecedesValidatedServerFloor() async throws { - let fixture = try RelayCoordinatorFixture() - let endpoint = TestIrohEndpoint(identity: fixture.identity) - let supervisor = try await fixture.activeSupervisor(endpoint: endpoint) - let clock = TestRelayClock(now: fixture.now) - var clockEvents = clock.events().makeAsyncIterator() - let coordinator = CmxIrohRelayCredentialCoordinator( - supervisor: supervisor, - broker: TestRelayTokenBroker(steps: [.rateLimited(600)]), - managedRelayURLs: Set(fixture.relayURLs), - clock: clock, - jitter: { _, refreshAfter in refreshAfter }, - retryJitter: { 0 } - ) - - try await coordinator.activate( - bindingID: fixture.bindingID, - endpointIdentity: fixture.identity - ) - - let clockEvent = await clockEvents.next() - #expect(await endpoint.observedRelayUpdates().isEmpty) - #expect(clockEvent == .sleep(fixture.now.addingTimeInterval(600))) - await coordinator.deactivate() - } - -} - -private actor TestRelayCredentialInstallRecorder { - private var responses: [CmxIrohRelayTokenResponse] = [] - private var waiters: [(Int, CheckedContinuation<Void, Never>)] = [] - - func record(_ response: CmxIrohRelayTokenResponse) { - responses.append(response) - let ready = waiters.filter { responses.count >= $0.0 } - waiters.removeAll { responses.count >= $0.0 } - for (_, continuation) in ready { continuation.resume() } - } - - func values() -> [CmxIrohRelayTokenResponse] { - responses - } - - func waitForCount(_ count: Int) async { - guard responses.count < count else { return } - await withCheckedContinuation { continuation in - waiters.append((count, continuation)) - } - } -} - -private actor TestRelayCredentialPersistenceGate { - private var started = false - private var startWaiters: [CheckedContinuation<Void, Never>] = [] - private var persistenceContinuation: CheckedContinuation<Void, Never>? - - func persist(_: CmxIrohRelayTokenResponse) async { - started = true - let waiters = startWaiters - startWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - await withCheckedContinuation { continuation in - persistenceContinuation = continuation - } - } - - func waitUntilStarted() async { - guard !started else { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } - - func resume() { - persistenceContinuation?.resume() - persistenceContinuation = nil - } -} - -actor TestRelayTokenBroker: CmxIrohRelayTokenServing { - enum Step: Sendable { - case response(CmxIrohRelayTokenResponse) - case failure - case rateLimited(Int) - } - - private var steps: [Step] - private var endpointIDs: [CmxIrohPeerIdentity] = [] - private var issueCount = 0 - private let issueHook: (@Sendable (_ count: Int) async -> Void)? - - init( - steps: [Step], - issueHook: (@Sendable (_ count: Int) async -> Void)? = nil - ) { - self.steps = steps - self.issueHook = issueHook - } - - func issueRelayToken( - bindingID _: String, - endpointID: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayTokenResponse { - endpointIDs.append(endpointID) - issueCount += 1 - await issueHook?(issueCount) - guard !steps.isEmpty else { throw TestRelayCoordinatorError.noResponse } - switch steps.removeFirst() { - case let .response(response): - return response - case .failure: - throw TestRelayCoordinatorError.transient - case let .rateLimited(retryAfterSeconds): - throw CmxIrohTrustBrokerClientError.rateLimited( - code: "rate_limited", - retryAfterSeconds: retryAfterSeconds - ) - } - } - - func observedEndpointIDs() -> [CmxIrohPeerIdentity] { - endpointIDs - } -} - -final class TestRelayClock: CmxIrohRelayClock, @unchecked Sendable { - enum Event: Equatable, Sendable { - case sleep(Date) - case cancelled - } - - private let lock = NSLock() - private var currentDate: Date - private var sleepers: [UUID: CheckedContinuation<Void, any Error>] = [:] - private var sleepDeadlines: [Date] = [] - private let eventStream: AsyncStream<Event> - private let continuation: AsyncStream<Event>.Continuation - - init(now: Date) { - currentDate = now - let events = AsyncStream<Event>.makeStream() - eventStream = events.stream - continuation = events.continuation - } - - func now() -> Date { - lock.withLock { currentDate } - } - - func sleep(until deadline: Date) async throws { - lock.withLock { sleepDeadlines.append(deadline) } - continuation.yield(.sleep(deadline)) - let id = UUID() - try await withTaskCancellationHandler { - try Task.checkCancellation() - try await withCheckedThrowingContinuation { sleeper in - lock.withLock { - sleepers[id] = sleeper - } - if Task.isCancelled { - cancelSleep(id: id) - } - } - } onCancel: { - cancelSleep(id: id) - } - } - - func advance(to date: Date) { - let pending = lock.withLock { () -> [CheckedContinuation<Void, any Error>] in - currentDate = date - defer { sleepers.removeAll() } - return Array(sleepers.values) - } - for sleeper in pending { - sleeper.resume() - } - } - - func setNowWithoutResuming(_ date: Date) { - lock.withLock { currentDate = date } - } - - func events() -> AsyncStream<Event> { - eventStream - } - - func observedSleepDeadlines() -> [Date] { - lock.withLock { sleepDeadlines } - } - - private func cancelSleep(id: UUID) { - let sleeper = lock.withLock { sleepers.removeValue(forKey: id) } - guard let sleeper else { return } - continuation.yield(.cancelled) - sleeper.resume(throwing: CancellationError()) - } -} - -private enum TestRelayCoordinatorError: Error { - case noResponse - case transient -} - -struct RelayCoordinatorFixture: Sendable { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let bindingID = "123e4567-e89b-42d3-a456-426614174010" - let identity: CmxIrohPeerIdentity - let relayURLs = [ - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - ] - - var refreshAfter: Date { - now.addingTimeInterval(12 * 60 * 60) - } - - var expiresAt: Date { - now.addingTimeInterval(24 * 60 * 60) - } - - init() throws { - identity = try CmxIrohPeerIdentity(endpointID: String(repeating: "ab", count: 32)) - } - - func activeSupervisor( - endpoint: TestIrohEndpoint - ) async throws -> CmxIrohEndpointSupervisor { - let supervisor = CmxIrohEndpointSupervisor( - factory: TestIrohEndpointFactory(endpoints: [endpoint]), - configuration: try CmxIrohEndpointConfiguration( - secretKey: CmxIrohSecretKey(bytes: Data(repeating: 7, count: 32)), - alpns: [CmxIrohProtocolConfiguration.cmuxMobileV1.alpn], - managedRelayURLs: Set(relayURLs), - relays: [] - ) - ) - _ = try await supervisor.activate() - return supervisor - } - - func response( - relayURLs: [String]? = nil, - tokens: [String]? = nil, - refreshAfter: Date? = nil, - expiresAt: Date? = nil - ) throws -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let urls = relayURLs ?? self.relayURLs - if let tokens { - guard tokens.count == urls.count else { - throw CmxIrohTrustBrokerClientError.invalidResponse - } - return CmxIrohRelayTokenResponse( - credentials: zip(urls, tokens).map { url, token in - CmxIrohManagedRelayCredential( - relayURL: url, - token: token, - expiresAt: formatter.string( - from: expiresAt ?? self.expiresAt - ), - refreshAfter: formatter.string( - from: refreshAfter ?? self.refreshAfter - ) - ) - } - ) - } - let object: [String: Any] = [ - "token": "abc234", - "expires_at": formatter.string(from: expiresAt ?? self.expiresAt), - "refresh_after": formatter.string(from: refreshAfter ?? self.refreshAfter), - "relay_fleet": urls, - ] - return try JSONDecoder().decode( - CmxIrohRelayTokenResponse.self, - from: JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift deleted file mode 100644 index 38c62ea3..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyBrokerTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite(.serialized) -struct CmxIrohRelayPolicyBrokerTests { - @Test - func bootstrapAcceptsRevisionZeroAndNullableToken() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "token": null, - "expiresAt": 1782000300, - "ttlSeconds": 300, - "relays": ["https://usc1.relay.cmux.dev"], - "policy": "aaa.bbb.ccc", - "preference": {"mode":"automatic"}, - "preferenceRevision": 0 - } - """ - ), - ]) - let client = try makeClient(transport: transport) - - let response = try await client.issueRelayBootstrap( - endpointID: CmxIrohPeerIdentity(endpointID: Self.endpointID) - ) - - #expect(response.relayToken == nil) - #expect(response.relayPolicy.preference == .automatic) - #expect(response.relayPolicy.preferenceRevision == 0) - let request = try #require(await transport.requests().first) - #expect(request.url?.path == "/api/relay/token") - #expect(request.httpMethod == "POST") - } - - @Test - func preferenceRoutesUseExactCanonicalWireSchema() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "preference": { - "mode": "managed", - "selectedManagedRelayIds": ["cmux-us"] - }, - "preferenceRevision": 0 - } - """ - ), - .json( - status: 200, - body: """ - { - "preference": { - "mode": "custom", - "customRelays": [{ - "id": "private-home", - "url": "https://relay.example.net:8443/", - "provider": "personal", - "region": "home", - "displayName": "Home relay", - "authMode": "device_secret" - }] - }, - "preferenceRevision": 1 - } - """ - ), - ]) - let client = try makeClient(transport: transport) - - let current = try await client.relayPreference() - let currentConfiguration = try CmxIrohAccountRelayConfiguration.managed(["cmux-us"]) - #expect(current.preference == currentConfiguration) - #expect(current.revision == 0) - - let definition = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net:8443/", - provider: "personal", - region: "home", - displayName: "Home relay", - authMode: .staticToken - ) - let updated = try await client.updateRelayPreference( - CmxIrohRelayPreferenceUpdateRequest( - expectedRevision: 0, - preference: .custom([definition]) - ) - ) - let updatedConfiguration = try CmxIrohAccountRelayConfiguration.custom([definition]) - #expect(updated.preference == updatedConfiguration) - #expect(updated.revision == 1) - - let requests = await transport.requests() - #expect(requests.map { $0.url?.path } == [ - "/api/relay/preferences", - "/api/relay/preferences", - ]) - #expect(requests.map(\.httpMethod) == ["GET", "PUT"]) - let body = try #require(requests[1].httpBody) - let object = try #require( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(object["expectedRevision"] as? Int == 0) - let preference = try #require(object["preference"] as? [String: Any]) - #expect(preference["mode"] as? String == "custom") - #expect(preference["relays"] == nil) - #expect(preference["selectedManagedRelayIds"] as? [String] == []) - let relays = try #require(preference["customRelays"] as? [[String: Any]]) - #expect(relays.first?["authMode"] as? String == "device_secret") - } - - private func makeClient( - transport: RecordingBrokerTransport - ) throws -> CmxIrohTrustBrokerClient { - try CmxIrohTrustBrokerClient( - baseURL: #require(URL(string: "https://cmux.example")), - tokenSource: CmxIrohBrokerTokenSource( - accessToken: { "access" }, - refreshToken: { "refresh" } - ), - transport: transport - ) - } - - private static let endpointID = - "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyCustomProfileTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyCustomProfileTests.swift deleted file mode 100644 index 560b74cc..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyCustomProfileTests.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohRelayPolicyTests { - @Test - func customProfileAllowsPrivateProviderPortWithoutManagedFallback() throws { - let first = try CmxIrohCustomRelay( - url: "https://relay.example.net:8443/", - authenticationToken: "private-token" - ) - let second = try CmxIrohCustomRelay(url: "https://backup.example.net/") - let profile = try CmxIrohCustomRelayProfile(relays: [first, second]) - - #expect(profile.relays.map(\.url) == [ - "https://relay.example.net:8443/", - "https://backup.example.net/", - ]) - #expect(profile.relays[0].authenticationToken == "private-token") - #expect(profile.relays[1].authenticationToken == nil) - - #expect(throws: CmxIrohRelayPolicyError.invalidSelection) { - try CmxIrohCustomRelayProfile(relays: [first, first]) - } - #expect(throws: CmxIrohRelayPolicyError.invalidClaims) { - try CmxIrohCustomRelay(url: "http://relay.example.net/") - } - #expect(throws: CmxIrohRelayPolicyError.invalidClaims) { - try CmxIrohCustomRelay( - url: "https://relay.example.net/", - authenticationToken: "invalid\nprovider-token" - ) - } - } - - @Test - func customProfileStoreKeepsTokensInSecureStorageAndRevalidatesRecords() async throws { - let secureStore = TestSecureCredentialStore() - let selectionStore = RelayPolicyTestInstallStateStore() - let store = CmxIrohCustomRelayProfileStore( - secureStore: secureStore, - selectionStore: selectionStore - ) - let profile = try CmxIrohCustomRelayProfile( - relays: [ - CmxIrohCustomRelay( - url: "https://private.example.net:8443/", - authenticationToken: "private-token" - ), - ] - ) - - try await store.save(profile) - #expect(try await store.load() == profile) - #expect(await store.loadSelection() == .custom(profile)) - #expect( - await secureStore.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - - let invalid = Data( - #"{"version":1,"relays":[{"url":"http://capture.example/","authenticationToken":null}]}"#.utf8 - ) - await secureStore.write( - invalid, - account: "active-custom-relay-profile", - accessibility: .afterFirstUnlockThisDeviceOnly - ) - #expect(try await store.load() == nil) - #expect(await store.loadSelection() == .customUnavailable) - #expect(await secureStore.recordCount() == 0) - - try await store.clear() - #expect(await store.loadSelection() == .managed) - } - - @Test - func selectedCustomProfileFailsClosedWhenSecureStorageIsUnavailable() async throws { - let secureStore = TestSecureCredentialStore() - let selectionStore = RelayPolicyTestInstallStateStore() - let store = CmxIrohCustomRelayProfileStore( - secureStore: secureStore, - selectionStore: selectionStore - ) - let profile = try CmxIrohCustomRelayProfile( - relays: [CmxIrohCustomRelay(url: "https://private.example.net/")] - ) - try await store.save(profile) - let unavailableStore = CmxIrohCustomRelayProfileStore( - secureStore: RelayPolicyUnavailableSecureStore(), - selectionStore: selectionStore - ) - - #expect(await unavailableStore.loadSelection() == .customUnavailable) - - let endpointProfile = CmxIrohEndpointRelayProfile.unavailableCustomOverride - #expect(endpointProfile.allowedRelayURLs.isEmpty) - #expect(endpointProfile.activeRelays.isEmpty) - #expect(endpointProfile.source == .custom) - } -} - -private final class RelayPolicyTestInstallStateStore: - CmxIrohInstallStateStoring, - @unchecked Sendable -{ - private let lock = NSLock() - private var values: [String: String] = [:] - - func string(forKey key: String) -> String? { - lock.withLock { values[key] } - } - - func set(_ value: String?, forKey key: String) { - lock.withLock { values[key] = value } - } -} - -private struct RelayPolicyUnavailableSecureStore: CmxIrohSecureCredentialStoring { - private struct Unavailable: Error {} - - func read(account: String) async throws -> Data? { - throw Unavailable() - } - - func write( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility - ) async throws {} - - func delete(account: String) async throws {} - - func deleteAll() async throws {} -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift deleted file mode 100644 index bbc5087f..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests+Preferences.swift +++ /dev/null @@ -1,384 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohRelayPolicyServiceTests { - @Test - func accountMetadataAndDeviceCredentialsStaySeparatedByAccountAndURL() async throws { - let secureStore = TestSecureCredentialStore() - let credentialStore = CmxIrohCustomRelayCredentialStore(secureStore: secureStore) - let relay = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net/", - provider: "personal", - region: "home", - authMode: .staticToken - ) - let configuration = try CmxIrohAccountRelayConfiguration.custom([relay]) - let token = "device-only-token" - - try await credentialStore.setStaticToken( - token, - relayID: relay.id, - relayURL: relay.url, - accountID: "account-a" - ) - - let accountMetadata = try JSONEncoder().encode(configuration) - #expect(String(decoding: accountMetadata, as: UTF8.self).contains(token) == false) - #expect( - try await credentialStore.staticTokens( - for: [relay], - accountID: "account-a" - )[relay.id] == token - ) - #expect( - try await credentialStore.staticTokens( - for: [relay], - accountID: "account-b" - ).isEmpty - ) - let movedRelay = try CmxIrohCustomRelayDefinition( - id: relay.id, - url: "https://replacement.example.net/", - provider: relay.provider, - region: relay.region, - authMode: relay.authMode - ) - #expect( - try await credentialStore.staticTokens( - for: [movedRelay], - accountID: "account-a" - ).isEmpty - ) - #expect( - await secureStore.observedAccessibilities() - == [.afterFirstUnlockThisDeviceOnly] - ) - } - - @Test - func missingStaticTokenDisablesWholeCustomProfileWithoutManagedFallback() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - let openRelay = try CmxIrohCustomRelayDefinition( - id: "open-relay", - url: "https://open.example.net/", - provider: "personal", - region: "home", - authMode: .none - ) - let authenticatedRelay = try CmxIrohCustomRelayDefinition( - id: "authenticated-relay", - url: "https://authenticated.example.net/", - provider: "personal", - region: "home", - authMode: .staticToken - ) - - let effective = try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .custom([openRelay, authenticatedRelay]), - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - #expect(effective.source == .customUnavailable) - #expect(effective.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(effective.endpointRelayProfile.activeRelays.isEmpty) - #expect(effective.relayBootstrap == nil) - #expect(effective.missingCredentialRelayIDs == [authenticatedRelay.id]) - #expect(await stores.service.diagnosticsSnapshot().failure == .missingCustomCredential) - } - - @Test - func dormantSelectionsAndCustomDefinitionsSurviveEveryActiveMode() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - let relay = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net/", - provider: "personal", - region: "home", - authMode: .none - ) - let configuration = try CmxIrohAccountRelayConfiguration( - mode: .automatic, - selectedManagedRelayIDs: ["cmux-us"], - customRelays: [relay] - ) - - let effective = try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: configuration, - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - #expect(effective.requestedConfiguration == configuration) - #expect(effective.requestedPreference == .automatic) - #expect(effective.source == .managed) - let managed = try configuration.updatingActivePreference(.managed(["cmux-us"])) - #expect(managed.customRelays == [relay]) - let custom = try managed.updatingActivePreference(.custom([relay])) - #expect(custom.selectedManagedRelayIDs == ["cmux-us"]) - #expect(custom.customRelays == [relay]) - } - - @Test - func authoritativeDeletionPrunesDeviceSecretWithoutChangingDormantMode() async throws { - let fixture = RelayPolicyServiceTestFixture() - let credentialStore = CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()), - credentialStore: credentialStore - ) - let relay = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net/", - provider: "personal", - region: "home", - authMode: .staticToken - ) - try await credentialStore.setStaticToken( - "device-only-token", - relayID: relay.id, - relayURL: relay.url, - accountID: "account-a" - ) - let saved = try CmxIrohAccountRelayConfiguration( - mode: .automatic, - selectedManagedRelayIDs: ["cmux-us"], - customRelays: [relay] - ) - _ = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: saved, - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - #expect( - try await credentialStore.staticTokens( - for: [relay], - accountID: "account-a" - )[relay.id] != nil - ) - - let removed = try saved.replacingCustomRelays([]) - _ = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 2), - preference: removed, - preferenceRevision: 2 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - #expect(removed.mode == .automatic) - #expect(removed.selectedManagedRelayIDs == ["cmux-us"]) - #expect( - try await credentialStore.staticTokens( - for: [relay], - accountID: "account-a" - ).isEmpty - ) - } - - @Test - func committedRemoteConfigurationWinsWhenLocalPersistenceFails() async throws { - let fixture = RelayPolicyServiceTestFixture() - let preferenceSecureStore = TestControllableSecureCredentialStore() - await preferenceSecureStore.failNextWrite() - let first = try CmxIrohAccountRelayConfiguration( - mode: .automatic, - selectedManagedRelayIDs: ["cmux-us"], - customRelays: [] - ) - let second = try first.updatingActivePreference(.managed(["cmux-us"])) - let broker = RelayPolicyServiceBroker(responses: [ - try CmxIrohRelayPreferenceResponse(preference: first, revision: 1), - try CmxIrohRelayPreferenceResponse(preference: second, revision: 2), - ]) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: CmxIrohRelayPreferenceStore(secureStore: preferenceSecureStore), - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ), - broker: broker - ) - - let reconciled = try await service.setConfiguration( - first, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(reconciled.requestedConfiguration == first) - #expect(reconciled.preferenceRevision == 1) - #expect(await service.accountConfiguration() == first) - #expect(await service.diagnosticsSnapshot().failure == .preferencePersistenceUnavailable) - - _ = try await service.setConfiguration( - second, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(await broker.expectedRevisions() == [nil, 1]) - #expect(await service.accountConfiguration() == second) - } - - @Test - func liveAuthoritativeRevisionAllowsUpdatesWhilePreferenceKeychainIsUnavailable() async throws { - let fixture = RelayPolicyServiceTestFixture() - let preferenceSecureStore = RelayPolicyServiceSwitchableSecureStore() - let initial = try CmxIrohAccountRelayConfiguration( - mode: .automatic, - selectedManagedRelayIDs: ["cmux-us"], - customRelays: [] - ) - let updated = try initial.updatingActivePreference(.managed(["cmux-us"])) - let broker = RelayPolicyServiceBroker(responses: [ - try CmxIrohRelayPreferenceResponse(preference: updated, revision: 2), - ]) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: CmxIrohRelayPreferenceStore(secureStore: preferenceSecureStore), - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ), - broker: broker - ) - _ = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: initial, - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - await preferenceSecureStore.setUnavailable(true) - - let effective = try await service.setConfiguration( - updated, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - #expect(effective.requestedConfiguration == updated) - #expect(await broker.expectedRevisions() == [1]) - #expect(await service.diagnosticsSnapshot().failure == .preferencePersistenceUnavailable) - } - - @Test - func relayURLChangeQuarantinesOldDeviceSecretUntilReauthenticated() async throws { - let fixture = RelayPolicyServiceTestFixture() - let oldRelay = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://old-relay.example.net/", - provider: "personal", - region: "home", - authMode: .staticToken - ) - let newRelay = try CmxIrohCustomRelayDefinition( - id: oldRelay.id, - url: "https://new-relay.example.net/", - provider: oldRelay.provider, - region: oldRelay.region, - authMode: .staticToken - ) - let oldConfiguration = try CmxIrohAccountRelayConfiguration.custom([oldRelay]) - let newConfiguration = try CmxIrohAccountRelayConfiguration.custom([newRelay]) - let credentialStore = CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ) - let broker = RelayPolicyServiceBroker(responses: [ - try CmxIrohRelayPreferenceResponse( - preference: newConfiguration, - revision: 2 - ), - ]) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: CmxIrohRelayPreferenceStore( - secureStore: TestSecureCredentialStore() - ), - credentialStore: credentialStore, - broker: broker - ) - _ = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: oldConfiguration, - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - let oldActive = try await service.setStaticCredential( - "old-provider-secret", - relayID: oldRelay.id, - relayURL: oldRelay.url, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(oldActive.endpointRelayProfile.activeRelays.first?.authenticationToken - == "old-provider-secret") - - let quarantined = try await service.setConfiguration( - newConfiguration, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - - #expect(quarantined.source == .customUnavailable) - #expect(quarantined.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(quarantined.missingCredentialRelayIDs == [newRelay.id]) - #expect(try await credentialStore.configuredRelayIDs(accountID: "account-a").isEmpty) - - let reauthenticated = try await service.setStaticCredential( - "new-provider-secret", - relayID: newRelay.id, - relayURL: newRelay.url, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(reauthenticated.source == .custom) - #expect(reauthenticated.endpointRelayProfile.allowedRelayURLs == [newRelay.url]) - #expect(reauthenticated.endpointRelayProfile.activeRelays.first?.authenticationToken - == "new-provider-secret") - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift deleted file mode 100644 index 272faa3d..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift +++ /dev/null @@ -1,523 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRelayPolicyServiceTests { - @Test - func staleManagedSelectionNarrowsWithoutWideningToAutomatic() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - let service = stores.service - let response = try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .managed(["cmux-us", "removed-relay"]), - preferenceRevision: 1 - ) - - let effective = try await service.install( - response: response, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - #expect(effective.effectivePreference == .managed(["cmux-us"])) - #expect(effective.staleRelayIDs == ["removed-relay"]) - #expect(effective.endpointRelayProfile.allowedRelayURLs == [fixture.relayURLs[0]]) - #expect(effective.managedSnapshot?.relays.map(\.id) == ["cmux-us"]) - #expect(effective.relayBootstrap == fixture.relayCredential()) - let stored = try #require( - try await stores.preferenceStore.load(accountID: "account-a") - ) - #expect(stored.effective == .managed(["cmux-us"])) - #expect(stored.staleRelayIDs == ["removed-relay"]) - - let fullyStale = try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 2), - preference: .managed(["removed-relay"]), - preferenceRevision: 2 - ) - let directOnly = try await service.install( - response: fullyStale, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - #expect(directOnly.source == .managedUnavailable) - #expect(directOnly.effectivePreference == nil) - #expect(directOnly.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(directOnly.relayBootstrap == nil) - #expect(await service.diagnosticsSnapshot().failure == .staleManagedSelection) - } - - @Test - func customStaticTokensStayDeviceLocalAndMissingTokenFailsClosed() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - let definition = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net:8443/", - provider: "personal", - region: "home", - displayName: "Home relay", - authMode: .staticToken - ) - let response = try CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .custom([definition]), - preferenceRevision: 1 - ) - - let missing = try await stores.service.install( - response: response, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - #expect(missing.source == .customUnavailable) - #expect(missing.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(missing.missingCredentialRelayIDs == ["private-home"]) - - let active = try await stores.service.setStaticCredential( - "private-secret-token", - relayID: "private-home", - relayURL: definition.url, - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(active.source == .custom) - #expect(active.endpointRelayProfile.allowedRelayURLs == [definition.url]) - #expect(active.endpointRelayProfile.activeRelays.first?.authenticationToken - == "private-secret-token") - let diagnostic = await stores.service.diagnosticsSnapshot() - #expect(diagnostic.selectedRelayCount == 1) - #expect(String(describing: diagnostic).contains(definition.url) == false) - #expect(String(describing: diagnostic).contains("private-secret-token") == false) - } - - @Test - func unauthenticatedCustomRelayDoesNotDependOnCredentialStorage() async throws { - let fixture = RelayPolicyServiceTestFixture() - let preferenceStore = CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: preferenceStore, - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: RelayPolicyServiceUnavailableSecureStore() - ) - ) - let definition = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net/", - provider: "personal", - region: "home", - authMode: .none - ) - - let effective = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .custom([definition]), - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: nil, - now: fixture.now - ) - - #expect(effective.source == .custom) - #expect(effective.endpointRelayProfile.allowedRelayURLs == [definition.url]) - #expect(await service.diagnosticsSnapshot().failure == .customCredentialUnavailable) - } - - @Test - func unavailableCustomCredentialStorageFailsClosedForStaticToken() async throws { - let fixture = RelayPolicyServiceTestFixture() - let preferenceStore = CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()) - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()), - preferenceStore: preferenceStore, - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: RelayPolicyServiceUnavailableSecureStore() - ) - ) - let definition = try CmxIrohCustomRelayDefinition( - id: "private-home", - url: "https://relay.example.net/", - provider: "personal", - region: "home", - authMode: .staticToken - ) - - let effective = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .custom([definition]), - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: nil, - now: fixture.now - ) - - #expect(effective.source == .customUnavailable) - #expect(effective.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(await service.diagnosticsSnapshot().failure == .customCredentialUnavailable) - } - - @Test - func stalledRelayPolicyLoadCannotDelayTCPStartup() async throws { - let fixture = RelayPolicyServiceTestFixture() - let trustRoot = try fixture.firstTrustRoot - let secureStore = RelayPolicyServiceSuspendedSecureStore() - let service = CmxIrohRelayPolicyService( - policyCache: CmxIrohRelayPolicyCache( - secureStore: TestSecureCredentialStore() - ), - preferenceStore: CmxIrohRelayPreferenceStore( - secureStore: secureStore - ), - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ) - ) - let tcpState = RelayPolicyServiceTCPState() - var activation: Task<Void, Never>? - - CmxIrohTCPFirstActivation.start( - startTCP: { tcpState.markStarted() }, - scheduleIroh: { - activation = Task { - _ = await service.restore( - accountID: "account-a", - trustRoot: trustRoot, - relayCredential: nil, - now: Date() - ) - } - } - ) - - await secureStore.waitUntilReadStarts() - #expect(tcpState.started) - #expect(activation != nil) - - await secureStore.resumeRead() - await activation?.value - #expect(await service.diagnosticsSnapshot().source == .managedUnavailable) - #expect(await service.diagnosticsSnapshot().failure == .policyUnavailable) - } - - @Test - func rollbackKeepsCurrentEffectivePolicyAndReportsFailure() async throws { - let fixture = RelayPolicyServiceTestFixture() - let service = makeStores().service - let first = try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 7), - preference: .automatic, - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - await #expect(throws: CmxIrohRelayPolicyError.rollback) { - try await service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 6), - preference: .automatic, - preferenceRevision: 2 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - } - - #expect(await service.effectivePolicy() == first) - #expect(await service.diagnosticsSnapshot().policySequence == 7) - #expect(await service.diagnosticsSnapshot().failure == .policyRollback) - } - - @Test - func preferenceRollbackIsRejectedBeforeNewPolicyCanAdvanceCache() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - _ = try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 7), - preference: .automatic, - preferenceRevision: 2 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - await #expect(throws: CmxIrohRelayPolicyServiceError.preferenceRollback) { - try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 8), - preference: .managed(["cmux-us"]), - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - } - let cached = try await stores.policyCache.load( - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(cached?.sequence == 7) - #expect(await stores.service.diagnosticsSnapshot().failure == .preferenceRollback) - } - - @Test - func cacheRestoresUntilSignedExpiryAndSupportsStagedKeyRotation() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - _ = try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .automatic, - preferenceRevision: 1 - ), - accountID: "account-a", - trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - _ = try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 2, signer: 2), - preference: .automatic, - preferenceRevision: 2 - ), - accountID: "account-a", - trustRoot: fixture.rotatedTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - let restored = await stores.service.restore( - accountID: "account-a", - trustRoot: try fixture.secondTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - #expect(restored.usedCachedPolicy) - #expect(restored.managedSnapshot?.policy.sequence == 2) - - let expired = await stores.service.restore( - accountID: "account-a", - trustRoot: try fixture.secondTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now.addingTimeInterval(3_600) - ) - #expect(expired.source == .managedUnavailable) - #expect(expired.endpointRelayProfile.allowedRelayURLs.isEmpty) - #expect(await stores.service.diagnosticsSnapshot().failure == .policyExpired) - } - - @Test - func implicitRevisionZeroStillRejectsEquivocation() async throws { - let fixture = RelayPolicyServiceTestFixture() - let stores = makeStores() - _ = try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 1), - preference: .automatic, - preferenceRevision: 0 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - - await #expect(throws: CmxIrohRelayPolicyServiceError.preferenceRollback) { - try await stores.service.install( - response: CmxIrohRelayPolicyResponse( - policy: fixture.token(sequence: 2), - preference: .managed(["cmux-us"]), - preferenceRevision: 0 - ), - accountID: "account-a", - trustRoot: fixture.firstTrustRoot, - relayCredential: fixture.relayCredential(), - now: fixture.now - ) - } - let cached = try await stores.policyCache.load( - trustRoot: fixture.firstTrustRoot, - now: fixture.now - ) - #expect(cached?.sequence == 1) - } - - func makeStores() -> ( - service: CmxIrohRelayPolicyService, - policyCache: CmxIrohRelayPolicyCache, - preferenceStore: CmxIrohRelayPreferenceStore - ) { - let policyCache = CmxIrohRelayPolicyCache(secureStore: TestSecureCredentialStore()) - let preferenceStore = CmxIrohRelayPreferenceStore(secureStore: TestSecureCredentialStore()) - return ( - CmxIrohRelayPolicyService( - policyCache: policyCache, - preferenceStore: preferenceStore, - credentialStore: CmxIrohCustomRelayCredentialStore( - secureStore: TestSecureCredentialStore() - ) - ), - policyCache, - preferenceStore - ) - } -} - -private struct RelayPolicyServiceUnavailableSecureStore: CmxIrohSecureCredentialStoring { - private struct Unavailable: Error {} - - func read(account: String) async throws -> Data? { throw Unavailable() } - func write( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility - ) async throws { throw Unavailable() } - func delete(account: String) async throws { throw Unavailable() } - func deleteAll() async throws { throw Unavailable() } -} - -private final class RelayPolicyServiceTCPState: @unchecked Sendable { - private let lock = NSLock() - private var value = false - - var started: Bool { - lock.withLock { value } - } - - func markStarted() { - lock.withLock { value = true } - } -} - -private actor RelayPolicyServiceSuspendedSecureStore: CmxIrohSecureCredentialStoring { - private var readStartedContinuation: CheckedContinuation<Void, Never>? - private var readContinuation: CheckedContinuation<Data?, Never>? - private var didStartRead = false - - func read(account _: String) async throws -> Data? { - didStartRead = true - readStartedContinuation?.resume() - readStartedContinuation = nil - return await withCheckedContinuation { continuation in - readContinuation = continuation - } - } - - func waitUntilReadStarts() async { - guard !didStartRead else { return } - await withCheckedContinuation { continuation in - readStartedContinuation = continuation - } - } - - func resumeRead() { - readContinuation?.resume(returning: nil) - readContinuation = nil - } - - func write( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility - ) async throws {} - - func delete(account: String) async throws {} - func deleteAll() async throws {} -} - -actor RelayPolicyServiceSwitchableSecureStore: CmxIrohSecureCredentialStoring { - private struct Unavailable: Error {} - private var records: [String: Data] = [:] - private var unavailable = false - - func setUnavailable(_ unavailable: Bool) { - self.unavailable = unavailable - } - - func read(account: String) throws -> Data? { - guard !unavailable else { throw Unavailable() } - return records[account] - } - - func write( - _ data: Data, - account: String, - accessibility _: CmxIrohSecureCredentialAccessibility - ) throws { - guard !unavailable else { throw Unavailable() } - records[account] = data - } - - func delete(account: String) throws { - guard !unavailable else { throw Unavailable() } - records.removeValue(forKey: account) - } - - func deleteAll() throws { - guard !unavailable else { throw Unavailable() } - records.removeAll(keepingCapacity: false) - } -} - -actor RelayPolicyServiceBroker: CmxIrohRelayPolicyServing { - private enum Failure: Error { case exhausted, unsupported } - - private var responses: [CmxIrohRelayPreferenceResponse] - private var requests: [CmxIrohRelayPreferenceUpdateRequest] = [] - - init(responses: [CmxIrohRelayPreferenceResponse]) { - self.responses = responses - } - - func issueRelayBootstrap( - endpointID _: CmxIrohPeerIdentity - ) async throws -> CmxIrohRelayBootstrapResponse { - throw Failure.unsupported - } - - func relayPreference() async throws -> CmxIrohRelayPreferenceResponse { - guard let response = responses.first else { throw Failure.exhausted } - return response - } - - func updateRelayPreference( - _ request: CmxIrohRelayPreferenceUpdateRequest - ) async throws -> CmxIrohRelayPreferenceResponse { - requests.append(request) - guard !responses.isEmpty else { throw Failure.exhausted } - return responses.removeFirst() - } - - func expectedRevisions() -> [Int64?] { - requests.map(\.expectedRevision) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift deleted file mode 100644 index 3b8a2909..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyTests.swift +++ /dev/null @@ -1,439 +0,0 @@ -import CryptoKit -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRelayPolicyTests { - @Test - func appPinnedTrustRootAcceptsCurrentAndStagedNextKeys() throws { - let current = Curve25519.Signing.PrivateKey() - let next = Curve25519.Signing.PrivateKey() - let trustRoot = CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ - "CMUXIrohRelayPolicyTrustKeys": [ - [ - "keyID": "policy-current", - "publicKeyBase64": current.publicKey.rawRepresentation.base64EncodedString(), - ], - [ - "keyID": "policy-next", - "publicKeyBase64": next.publicKey.rawRepresentation.base64EncodedString(), - ], - ], - ]) - - #expect(trustRoot?.keys.map(\.keyID) == ["policy-current", "policy-next"]) - } - - @Test - func appPinnedTrustRootFailsClosedForPartialRotationConfiguration() throws { - let current = Curve25519.Signing.PrivateKey() - let trustRoot = CmxIrohRelayPolicyTrustRoot.appPinned(infoDictionary: [ - "CMUXIrohRelayPolicyTrustKeys": [ - [ - "keyID": "policy-current", - "publicKeyBase64": current.publicKey.rawRepresentation.base64EncodedString(), - ], - ["keyID": "policy-next"], - ], - "CMUXIrohRelayPolicyKeyID": "policy-current", - "CMUXIrohRelayPolicyPublicKeyBase64": current.publicKey.rawRepresentation - .base64EncodedString(), - ]) - - #expect(trustRoot == nil) - } - - @Test - func signatureAuthorizesCatalogAndSelectionFiltersByStableID() throws { - let fixture = try Fixture() - let token = try fixture.token(sequence: 7) - - let policy = try CmxIrohRelayPolicyVerifier().verify( - token, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - let automatic = try CmxIrohRelayPolicySnapshot( - policy: policy, - selection: .automatic - ) - #expect(automatic.relayURLs == Set(fixture.relayURLs)) - - let selected = try CmxIrohRelayPolicySnapshot( - policy: policy, - selection: .only(["cmux-eu"]) - ) - #expect(selected.relays.map(\.id) == ["cmux-eu"]) - #expect(selected.relayURLs == [fixture.relayURLs[1]]) - } - - @Test - func policyAcceptsServerDisplayLabelsAndExplicitHTTPSPorts() throws { - let fixture = try Fixture() - let token = try fixture.token( - sequence: 8, - relayURLs: [ - "https://usc1.relay.cmux.dev:8443/", - fixture.relayURLs[1], - ], - regions: ["US Central", "Europe West"] - ) - - let policy = try CmxIrohRelayPolicyVerifier().verify( - token, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - #expect(policy.relays.map(\.region) == ["US Central", "Europe West"]) - #expect(policy.relays[0].url == "https://usc1.relay.cmux.dev:8443/") - } - - @Test - func substitutedRelayAndUnknownSelectionFailClosed() throws { - let fixture = try Fixture() - let valid = try fixture.token(sequence: 7) - let segments = valid.split(separator: ".", omittingEmptySubsequences: false) - let substitutedPayload = try fixture.payload( - sequence: 7, - relayURLs: [ - fixture.relayURLs[0], - "https://capture.example.com/", - ] - ) - let substituted = [ - String(segments[0]), - Fixture.base64URL(substitutedPayload), - String(segments[2]), - ].joined(separator: ".") - - #expect(throws: CmxIrohRelayPolicyError.invalidSignature) { - try CmxIrohRelayPolicyVerifier().verify( - substituted, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - - let policy = try CmxIrohRelayPolicyVerifier().verify( - valid, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - #expect(throws: CmxIrohRelayPolicyError.invalidSelection) { - try CmxIrohRelayPolicySnapshot( - policy: policy, - selection: .only(["removed-relay"]) - ) - } - } - - @Test - func policyTimeProtocolAndKeyIDAreStrict() throws { - let fixture = try Fixture() - let expired = try fixture.token( - sequence: 1, - expiresAt: fixture.nowSeconds + 60 - ) - #expect(throws: CmxIrohRelayPolicyError.expired) { - try CmxIrohRelayPolicyVerifier().verify( - expired, - trustRoot: fixture.trustRoot, - now: fixture.now.addingTimeInterval(60) - ) - } - - let unsupported = try fixture.token( - sequence: 2, - relayProtocol: "iroh-relay-v2" - ) - #expect(throws: CmxIrohRelayPolicyError.unsupportedRelayProtocol) { - try CmxIrohRelayPolicyVerifier().verify( - unsupported, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - - let unknownKey = try fixture.token(sequence: 3, keyID: "future-key") - #expect(throws: CmxIrohRelayPolicyError.unknownKeyID) { - try CmxIrohRelayPolicyVerifier().verify( - unknownKey, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - } - - @Test - func policyAcceptsOnlyBoundedNotBeforeClockSkew() throws { - let fixture = try Fixture() - let tolerated = try fixture.token( - sequence: 4, - notBefore: fixture.nowSeconds + 30 - ) - - #expect(throws: Never.self) { - try CmxIrohRelayPolicyVerifier().verify( - tolerated, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - - let excessive = try fixture.token( - sequence: 5, - notBefore: fixture.nowSeconds + 31 - ) - #expect(throws: CmxIrohRelayPolicyError.invalidClaims) { - try CmxIrohRelayPolicyVerifier().verify( - excessive, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - } - - @Test - func cacheRejectsPolicyRollbackAndReverifiesOnLoad() async throws { - let fixture = try Fixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohRelayPolicyCache(secureStore: store) - let sequenceSeven = try fixture.token(sequence: 7) - - let installed = try await cache.install( - signedPolicy: sequenceSeven, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - #expect(installed.sequence == 7) - #expect(await store.observedAccessibilities() == [.afterFirstUnlockThisDeviceOnly]) - - let sequenceSix = try fixture.token(sequence: 6) - await #expect(throws: CmxIrohRelayPolicyError.rollback) { - try await cache.install( - signedPolicy: sequenceSix, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - let equivocatedSequenceSeven = try fixture.token( - sequence: 7, - relayURLs: [ - fixture.relayURLs[0], - "https://alternate.relay.cmux.dev/", - ] - ) - await #expect(throws: CmxIrohRelayPolicyError.rollback) { - try await cache.install( - signedPolicy: equivocatedSequenceSeven, - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - let restored = try await cache.load( - trustRoot: fixture.trustRoot, - now: fixture.now - ) - #expect(restored?.sequence == 7) - } - - @Test - func cacheAcceptsRenewedEnvelopeForUnchangedCatalog() async throws { - let fixture = try Fixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohRelayPolicyCache(secureStore: store) - _ = try await cache.install( - signedPolicy: fixture.token(sequence: 7), - trustRoot: fixture.trustRoot, - now: fixture.now - ) - - let renewalTime = fixture.now.addingTimeInterval(120) - let renewed = try fixture.token( - sequence: 7, - issuedAt: fixture.nowSeconds + 120, - expiresAt: fixture.nowSeconds + 3_720 - ) - let installed = try await cache.install( - signedPolicy: renewed, - trustRoot: fixture.trustRoot, - now: renewalTime - ) - - #expect(installed.sequence == 7) - #expect(installed.issuedAt == fixture.nowSeconds + 120) - #expect( - try await cache.load(trustRoot: fixture.trustRoot, now: renewalTime)?.expiresAt - == fixture.nowSeconds + 3_720 - ) - } - - @Test - func corruptPolicyCacheCannotEraseTheRollbackFloor() async throws { - let fixture = try Fixture() - let store = TestSecureCredentialStore() - let cache = CmxIrohRelayPolicyCache(secureStore: store) - _ = try await cache.install( - signedPolicy: fixture.token(sequence: 7), - trustRoot: fixture.trustRoot, - now: fixture.now - ) - await store.write( - Data("corrupt".utf8), - account: "managed-relay-policy", - accessibility: .afterFirstUnlockThisDeviceOnly - ) - - await #expect(throws: CmxIrohRelayPolicyError.invalidClaims) { - try await cache.load(trustRoot: fixture.trustRoot, now: fixture.now) - } - await #expect(throws: CmxIrohRelayPolicyError.invalidClaims) { - try await cache.install( - signedPolicy: fixture.token(sequence: 6), - trustRoot: fixture.trustRoot, - now: fixture.now - ) - } - #expect(await store.recordCount() == 1) - } - - @Test - func endpointProfileRequiresExactCredentialsForVerifiedSelection() throws { - let fixture = try Fixture() - let policy = try CmxIrohRelayPolicyVerifier().verify( - fixture.token(sequence: 7), - trustRoot: fixture.trustRoot, - now: fixture.now - ) - let snapshot = try CmxIrohRelayPolicySnapshot( - policy: policy, - selection: .only(["cmux-eu"]) - ) - let selected = try fixture.relayConfiguration(url: fixture.relayURLs[1]) - let profile = try CmxIrohEndpointRelayProfile( - snapshot: snapshot, - relays: [selected] - ) - - #expect(profile.allowedRelayURLs == [fixture.relayURLs[1]]) - #expect(profile.managedRelays == [selected]) - #expect(throws: CmxIrohEndpointConfigurationError.incompleteManagedRelayCredentials) { - try CmxIrohEndpointRelayProfile(snapshot: snapshot, relays: []) - } - let substituted = try fixture.relayConfiguration( - url: "https://capture.example.com/" - ) - #expect( - throws: CmxIrohEndpointConfigurationError.unmanagedRelayURL(substituted.url) - ) { - try CmxIrohEndpointRelayProfile(snapshot: snapshot, relays: [substituted]) - } - } - - private struct Fixture { - let privateKey: Curve25519.Signing.PrivateKey - let trustRoot: CmxIrohRelayPolicyTrustRoot - let now = Date(timeIntervalSince1970: 1_782_000_000) - let relayURLs = [ - "https://usc1.relay.cmux.dev/", - "https://euw4.relay.cmux.dev/", - ] - - var nowSeconds: Int64 { Int64(now.timeIntervalSince1970) } - - init() throws { - privateKey = Curve25519.Signing.PrivateKey() - let key = try CmxIrohRelayPolicyVerificationKey( - keyID: "policy-2026-1", - rawPublicKeyBase64: privateKey.publicKey.rawRepresentation.base64EncodedString() - ) - trustRoot = try CmxIrohRelayPolicyTrustRoot(keys: [key]) - } - - func token( - sequence: Int64, - issuedAt: Int64? = nil, - notBefore: Int64? = nil, - expiresAt: Int64? = nil, - relayProtocol: String = "iroh-relay-v1", - keyID: String = "policy-2026-1", - relayURLs: [String]? = nil, - regions: [String]? = nil - ) throws -> String { - let header = try JSONSerialization.data( - withJSONObject: [ - "alg": "EdDSA", - "typ": "cmux-relay-policy-v1+jwt", - "kid": keyID, - ], - options: [.sortedKeys] - ) - let payload = try payload( - sequence: sequence, - relayURLs: relayURLs, - regions: regions, - issuedAt: issuedAt, - notBefore: notBefore, - expiresAt: expiresAt, - relayProtocol: relayProtocol - ) - let signingInput = "\(Self.base64URL(header)).\(Self.base64URL(payload))" - let signature = try privateKey.signature(for: Data(signingInput.utf8)) - return "\(signingInput).\(Self.base64URL(signature))" - } - - func payload( - sequence: Int64, - relayURLs: [String]? = nil, - regions: [String]? = nil, - issuedAt: Int64? = nil, - notBefore: Int64? = nil, - expiresAt: Int64? = nil, - relayProtocol: String = "iroh-relay-v1" - ) throws -> Data { - let urls = relayURLs ?? self.relayURLs - let relayIDs = ["cmux-us", "cmux-eu"] - let regions = regions ?? ["us-central1", "europe-west4"] - let relays = urls.enumerated().map { index, url in - [ - "id": relayIDs[index], - "provider": "cmux", - "region": regions[index], - "url": url, - ] - } - return try JSONSerialization.data( - withJSONObject: [ - "version": 1, - "jti": "123e4567-e89b-42d3-a456-426614174000", - "sequence": sequence, - "iat": issuedAt ?? nowSeconds, - "nbf": notBefore ?? issuedAt ?? nowSeconds, - "exp": expiresAt ?? nowSeconds + 3_600, - "aud": "cmux-iroh-relay-policy", - "relay_protocol": relayProtocol, - "relays": relays, - ], - options: [.sortedKeys] - ) - } - - static func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - func relayConfiguration(url: String) throws -> CmxIrohRelayConfiguration { - try CmxIrohRelayConfiguration( - url: url, - token: "aaaa", - expiresAt: now.addingTimeInterval(3_600), - refreshAfter: now.addingTimeInterval(1_800), - now: now - ) - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRetryScheduleTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRetryScheduleTests.swift deleted file mode 100644 index 633c3e9f..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRetryScheduleTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRetryScheduleTests { - @Test("invalid schedule inputs normalize to safe positive bounds") - func invalidInputsNormalize() { - let schedule = CmxIrohRetrySchedule( - initialDelay: -1, - maximumDelay: 0, - jitterFraction: 2 - ) - - #expect(schedule.initialDelay == 1) - #expect(schedule.maximumDelay == 1) - #expect(schedule.jitterFraction == 1) - #expect(schedule.delay( - failureCount: -1, - retryAfterSeconds: nil, - jitterUnitInterval: 2 - ) == 1) - } - - @Test - func growsExponentiallyWithPositiveJitterAndCaps() { - let schedule = CmxIrohRetrySchedule() - - #expect(schedule.delay( - failureCount: 0, - retryAfterSeconds: nil, - jitterUnitInterval: 0 - ) == 30) - #expect(schedule.delay( - failureCount: 1, - retryAfterSeconds: nil, - jitterUnitInterval: 0 - ) == 60) - #expect(schedule.delay( - failureCount: 0, - retryAfterSeconds: nil, - jitterUnitInterval: 1 - ) == 37.5) - #expect(schedule.delay( - failureCount: 20, - retryAfterSeconds: nil, - jitterUnitInterval: 1 - ) == 3_600) - } - - @Test - func retryAfterIsAFloorBeforeJitter() { - let schedule = CmxIrohRetrySchedule() - - #expect(schedule.delay( - failureCount: 0, - retryAfterSeconds: 600, - jitterUnitInterval: 0 - ) == 600) - #expect(schedule.delay( - failureCount: 0, - retryAfterSeconds: 600, - jitterUnitInterval: 1 - ) == 750) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRuntimeConfigurationDeviceIDTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRuntimeConfigurationDeviceIDTests.swift deleted file mode 100644 index bd0e2267..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRuntimeConfigurationDeviceIDTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohRuntimeConfigurationDeviceIDTests { - @Test - func runtimeConfigurationsCanonicalizeUUIDsWithoutFoldingOpaqueDeviceIDs() throws { - let fixture = try HostRuntimeFixture() - let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" - let lowercaseUUID = uppercaseUUID.lowercased() - - let uuidHost = hostConfiguration(deviceID: uppercaseUUID, fixture: fixture) - let opaqueHost = hostConfiguration(deviceID: "Legacy-Mac-ID", fixture: fixture) - let uuidClient = clientConfiguration(deviceID: uppercaseUUID, fixture: fixture) - let opaqueClient = clientConfiguration(deviceID: "Legacy-iOS-ID", fixture: fixture) - - #expect(uuidHost.deviceID == lowercaseUUID) - #expect(opaqueHost.deviceID == "Legacy-Mac-ID") - #expect(uuidClient.deviceID == lowercaseUUID) - #expect(opaqueClient.deviceID == "Legacy-iOS-ID") - } - - private func hostConfiguration( - deviceID: String, - fixture: HostRuntimeFixture - ) -> CmxIrohHostRuntimeConfiguration { - CmxIrohHostRuntimeConfiguration( - accountID: "account-a", - deviceID: deviceID, - appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", - tag: "test", - displayName: nil, - identity: fixture.identity, - pairingEnabled: true, - capabilities: [], - managedRelayURLs: [] - ) - } - - private func clientConfiguration( - deviceID: String, - fixture: HostRuntimeFixture - ) -> CmxIrohClientRuntimeConfiguration { - CmxIrohClientRuntimeConfiguration( - accountID: "account-a", - deviceID: deviceID, - appInstanceID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", - tag: "test", - displayName: nil, - identity: fixture.identity, - capabilities: [], - managedRelayURLs: [] - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift deleted file mode 100644 index 5d863b36..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSelectedTransportPathTests.swift +++ /dev/null @@ -1,123 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohSelectedTransportPathTests { - @Test - func selectedIPPathsAreReducedToPublicOrPrivateCategories() { - let publicPath = CmxIrohObservedConnectionPath(snapshots: [ - snapshot(address: "203.0.113.40:443", isIP: true), - ]) - let lanPath = CmxIrohObservedConnectionPath(snapshots: [ - snapshot(address: "192.168.1.20:443", isIP: true), - ]) - let tailscalePath = CmxIrohObservedConnectionPath(snapshots: [ - snapshot(address: "100.100.20.40:443", isIP: true), - ]) - let ipv6Path = CmxIrohObservedConnectionPath(snapshots: [ - snapshot(address: "[fd12:3456::9]:443", isIP: true), - ]) - let scopedIPv6Path = CmxIrohObservedConnectionPath(snapshots: [ - snapshot(address: "[fe80::1%en0]:443", isIP: true), - ]) - - #expect(publicPath == .direct) - #expect(lanPath == .privateNetwork) - #expect(tailscalePath == .privateNetwork) - #expect(ipv6Path == .privateNetwork) - #expect(scopedIPv6Path == .privateNetwork) - } - - @Test - func managedRelayAttributionComesFromVerifiedPolicyLabels() throws { - let url = "https://use1.relay.cmux.dev/" - let descriptor = CmxIrohManagedRelayDescriptor( - id: "cmux-use1", - provider: "cmux", - region: "us-east1", - url: url - ) - let policy = CmxIrohManagedRelayPolicy( - version: 1, - policyID: "123e4567-e89b-42d3-a456-426614174000", - sequence: 7, - issuedAt: 1_782_000_000, - notBefore: 1_782_000_000, - expiresAt: 1_782_003_600, - audience: "cmux-iroh-relay-policy", - relayProtocol: "iroh-relay-v1", - relays: [descriptor] - ) - let endpointProfile = try CmxIrohEndpointRelayProfile( - managedRelayURLs: [url], - relays: [] - ) - let effective = CmxIrohEffectiveRelayPolicy( - endpointRelayProfile: endpointProfile, - managedSnapshot: nil, - managedPolicy: policy, - requestedConfiguration: .automatic, - effectivePreference: .automatic, - source: .managed, - usedCachedPolicy: false, - preferenceRevision: 3 - ) - let classifier = CmxIrohSelectedTransportPathClassifier(policy: effective) - - #expect(classifier.classify(.relay(url: url)) == .managedRelay( - provider: "cmux", - region: "us-east1" - )) - #expect(classifier.classify(.relay(url: "https://substituted.example/")) == .unavailable) - } - - @Test - func customRelayAttributionUsesOnlyEffectiveAccountDefinition() throws { - let url = "https://relay.example.net:8443/" - let definition = try CmxIrohCustomRelayDefinition( - id: "office", - url: url, - provider: "My Network", - region: "Office", - displayName: "Office Relay", - authMode: .none - ) - let endpointProfile = CmxIrohEndpointRelayProfile( - customProfile: try CmxIrohCustomRelayProfile( - relays: [try CmxIrohCustomRelay(url: url)] - ) - ) - let effective = CmxIrohEffectiveRelayPolicy( - endpointRelayProfile: endpointProfile, - managedSnapshot: nil, - managedPolicy: nil, - requestedConfiguration: try .custom([definition]), - effectivePreference: .custom([definition]), - source: .custom, - usedCachedPolicy: false, - preferenceRevision: 5 - ) - let classifier = CmxIrohSelectedTransportPathClassifier(policy: effective) - - #expect(classifier.classify(.relay(url: url)) == .customRelay( - displayName: "Office Relay", - provider: "My Network", - region: "Office" - )) - } - - private func snapshot( - address: String, - isIP: Bool = false, - isRelay: Bool = false - ) -> CmxIrohConnectionPathSnapshot { - CmxIrohConnectionPathSnapshot( - isSelected: true, - remoteAddress: address, - isIP: isIP, - isRelay: isRelay - ) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift deleted file mode 100644 index b4645b6c..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTestDoubles.swift +++ /dev/null @@ -1,58 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -actor ServerSessionManualClock: CmxIrohRelayClock { - private var sleeper: CheckedContinuation<Void, Never>? - private var sleepWaiters: [CheckedContinuation<Void, Never>] = [] - - nonisolated func now() -> Date { - Date(timeIntervalSince1970: 1_800_000_000) - } - - func sleep(until _: Date) async throws { - let waiters = sleepWaiters - sleepWaiters.removeAll() - for waiter in waiters { waiter.resume() } - await withTaskCancellationHandler { - await withCheckedContinuation { sleeper = $0 } - } onCancel: { - Task { await self.cancelSleep() } - } - try Task.checkCancellation() - } - - func waitUntilSleeping() async { - if sleeper != nil { return } - await withCheckedContinuation { sleepWaiters.append($0) } - } - - func fire() { - sleeper?.resume() - sleeper = nil - } - - private func cancelSleep() { - sleeper?.resume() - sleeper = nil - } -} - -actor FixedAdmissionAuthorizer: CmxIrohAdmissionAuthorizing { - private let authorization: CmxIrohAdmissionAuthorization - private var observedCalls = 0 - - init(authorization: CmxIrohAdmissionAuthorization) { - self.authorization = authorization - } - - func authorize( - credential _: CmxIrohAdmissionCredential, - authenticatedPeerID _: CmxIrohPeerIdentity - ) -> CmxIrohAdmissionAuthorization { - observedCalls += 1 - return authorization - } - - func callCount() -> Int { observedCalls } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests+Support.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests+Support.swift deleted file mode 100644 index 583bd82a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests+Support.swift +++ /dev/null @@ -1,65 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -struct ServerFixture { - let peerID: CmxIrohPeerIdentity - let admittedPeer: CmxIrohAdmittedPeer - let authorizer: FixedAdmissionAuthorizer - let headerCodec = try! CmxIrohStreamHeaderCodec() - let controlSend: TestIrohSendStream - let controlStream: CmxIrohBidirectionalStream - - init( - decision: CmxIrohAdmissionDecision, - clientReadyFrame: Data? = admissionFrame(status: 2), - applicationBytes: Data = Data("rpc".utf8), - eventRecorder: TestIrohEventRecorder? = nil - ) throws { - let peerID = try CmxIrohPeerIdentity(endpointID: String(repeating: "a", count: 64)) - let admittedPeer = CmxIrohAdmittedPeer( - bindingID: "123e4567-e89b-42d3-a456-426614174001", - deviceID: "123e4567-e89b-42d3-a456-426614174002", - endpointID: peerID, - identityGeneration: 7, - platform: .ios - ) - self.peerID = peerID - self.admittedPeer = admittedPeer - let authorization: CmxIrohAdmissionAuthorization = switch decision { - case .accepted: - .accepted(admittedPeer, onlineLease: nil) - case let .denied(code): - .denied(code: code) - } - authorizer = FixedAdmissionAuthorizer(authorization: authorization) - controlSend = TestIrohSendStream( - eventRecorder: eventRecorder, - eventName: "control.send" - ) - let credential = try CmxIrohAdmissionCredential.pairGrant("aa.bb.cc") - let header = try headerCodec.encode( - CmxIrohStreamHeader(lane: .control, credential: credential) - ) - let readyFrame = if decision == .accepted { - clientReadyFrame ?? Data() - } else { - Data() - } - controlStream = CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream( - buffer: header + readyFrame + applicationBytes - ), - sendStream: controlSend - ) - } -} - -func admissionFrame(status: UInt8, code: UInt16 = 0) -> Data { - var frame = Data("CMXA".utf8) - frame.append(1) - frame.append(status) - let bigEndian = code.bigEndian - withUnsafeBytes(of: bigEndian) { frame.append(contentsOf: $0) } - return frame -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests.swift deleted file mode 100644 index acf32e56..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests.swift +++ /dev/null @@ -1,459 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohServerSessionTests { - @Test - func acceptedControlPreservesPayloadAndUnlocksIndependentLanes() async throws { - let events = TestIrohEventRecorder() - let fixture = try ServerFixture(decision: .accepted, eventRecorder: events) - let terminalID = try CmxIrohResourceID("terminal-1") - let terminalHeader = try fixture.headerCodec.encode( - CmxIrohStreamHeader(lane: .terminal(resourceID: terminalID, cursor: nil)) - ) - let terminalReceive = TestIrohReceiveStream( - buffer: terminalHeader + Data("terminal-payload".utf8) - ) - let terminalSend = TestIrohSendStream() - let artifactID = try CmxIrohResourceID("artifact:admitted-preview") - let artifactHeader = try fixture.headerCodec.encode( - CmxIrohStreamHeader(lane: .artifact(resourceID: artifactID, offset: 4)) - ) - let artifactReceive = TestIrohReceiveStream( - buffer: artifactHeader + Data("artifact-payload".utf8) - ) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - fixture.controlStream, - CmxIrohBidirectionalStream( - receiveStream: terminalReceive, - sendStream: terminalSend - ), - CmxIrohBidirectionalStream( - receiveStream: artifactReceive, - sendStream: TestIrohSendStream() - ), - ], - eventRecorder: events - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer, - protocolConfiguration: .testApplicationLanes - ) - - let admittedPeer = try await session.admit() - #expect(await connection.observedIncomingStreamLimits() == ["1:0", "17:0"]) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedNatTraversalActivationCount() == 1) - #expect(admittedPeer == fixture.admittedPeer) - #expect(await events.observedEvents() == [ - "connection.limits:1:0", - "connection.openBidirectionalStream", - "control.send", - "connection.authorizeNatTraversal", - "control.send", - "connection.limits:17:0", - ]) - #expect(try await session.receiveControl() == Data("rpc".utf8)) - let inbound = try await session.acceptBidirectionalLane() - #expect(inbound.lane == .terminal(resourceID: terminalID, cursor: nil)) - #expect( - try await inbound.stream.receiveStream.receive(maximumByteCount: 64) - == Data("terminal-payload".utf8) - ) - let artifact = try await session.acceptBidirectionalLane() - #expect(artifact.lane == .artifact(resourceID: artifactID, offset: 4)) - #expect( - try await artifact.stream.receiveStream.receive(maximumByteCount: 64) - == Data("artifact-payload".utf8) - ) - let acknowledgements = await fixture.controlSend.observedSentBuffers() - let acceptedPending = try #require(acknowledgements.first) - let serverReady = try #require(acknowledgements.dropFirst().first) - #expect(acknowledgements.count == 2) - #expect(try CmxIrohAdmissionAckCodec().decodePrefix(acceptedPending) == .accepted) - #expect(serverReady == admissionFrame(status: 3)) - try await session.sendControl(Data("control-after-artifact".utf8)) - #expect( - await fixture.controlSend.observedSentBuffers().last - == Data("control-after-artifact".utf8) - ) - #expect(await connection.observedCloseCallCount() == 0) - } - - @Test - func productionV1DoesNotGrantOrConsumeReservedApplicationLanes() async throws { - let fixture = try ServerFixture(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - fixture.controlStream, - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: TestIrohSendStream() - ), - ] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - _ = try await session.admit() - - await #expect(throws: CmxIrohServerSessionError.applicationLanesUnavailable) { - _ = try await session.acceptBidirectionalLane() - } - await #expect(throws: CmxIrohServerSessionError.applicationLanesUnavailable) { - _ = try await session.openSendLane( - .artifact( - resourceID: CmxIrohResourceID("artifact:reserved"), - offset: 0 - ), - priority: 10 - ) - } - #expect(await connection.observedIncomingStreamLimits() == ["1:0"]) - #expect(await connection.observedBidirectionalStreamOpenCount() == 1) - } - - @Test - func relayOnlyAdmissionCompletesBarrierWithoutAuthorizingNatTraversal() async throws { - let events = TestIrohEventRecorder() - let fixture = try ServerFixture(decision: .accepted, eventRecorder: events) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [fixture.controlStream], - eventRecorder: events - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer, - protocolConfiguration: .testRelayOnlyApplicationLanes - ) - - _ = try await session.admit() - - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedNatTraversalActivationCount() == 0) - #expect(await connection.observedIncomingStreamLimits() == ["1:0", "17:0"]) - #expect(await events.observedEvents() == [ - "connection.limits:1:0", - "connection.openBidirectionalStream", - "control.send", - "control.send", - "connection.limits:17:0", - ]) - let acknowledgements = await fixture.controlSend.observedSentBuffers() - #expect( - try CmxIrohAdmissionAckCodec().decodeFramePrefix( - try #require(acknowledgements.first) - ) == .acceptedRelayOnly - ) - #expect( - try CmxIrohAdmissionAckCodec().decodeFramePrefix( - try #require(acknowledgements.dropFirst().first) - ) == .serverReady - ) - } - - @Test - func denialSendsFixedAckThenClosesTheConnection() async throws { - let fixture = try ServerFixture(decision: .denied(code: 7)) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [fixture.controlStream] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - await #expect(throws: CmxIrohServerSessionError.admissionDenied(code: 7)) { - try await session.admit() - } - let ack = try #require(await fixture.controlSend.observedSentBuffers().first) - #expect(try CmxIrohAdmissionAckCodec().decodePrefix(ack) == .denied(code: 7)) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func missingClientReadyFailsClosedWithoutAuthorizingNatTraversal() async throws { - let fixture = try ServerFixture( - decision: .accepted, - clientReadyFrame: nil, - applicationBytes: Data() - ) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [fixture.controlStream] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - await #expect(throws: CmxIrohServerSessionError.unexpectedEndOfStream) { - try await session.admit() - } - - #expect(await fixture.controlSend.observedSentBuffers().count == 1) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func cancellationWhileWaitingForClientReadyNeverAuthorizesNatTraversal() async throws { - let fixture = try ServerFixture(decision: .accepted) - let credential = try CmxIrohAdmissionCredential.pairGrant("aa.bb.cc") - let header = try fixture.headerCodec.encode( - CmxIrohStreamHeader(lane: .control, credential: credential) - ) - let receive = TestBlockingIrohReceiveStream(buffer: header) - let controlSend = TestIrohSendStream() - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - CmxIrohBidirectionalStream( - receiveStream: receive, - sendStream: controlSend - ), - ] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - var blocked = await receive.blockedEvents().makeAsyncIterator() - let admission = Task { try await session.admit() } - _ = await blocked.next() - - await #expect(throws: CmxIrohServerSessionError.alreadyAdmitted) { - try await session.admit() - } - await #expect(throws: CmxIrohServerSessionError.notAdmitted) { - _ = try await session.acceptBidirectionalLane() - } - #expect(await connection.observedBidirectionalStreamOpenCount() == 1) - - admission.cancel() - - await #expect(throws: CancellationError.self) { - try await admission.value - } - #expect(await controlSend.observedSentBuffers().count == 1) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func roleInvalidClientReadyFailsClosedWithoutAuthorizingNatTraversal() async throws { - let fixture = try ServerFixture( - decision: .accepted, - clientReadyFrame: admissionFrame(status: 3) - ) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [fixture.controlStream] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - await #expect(throws: CmxIrohServerSessionError.invalidAdmissionFrame) { - try await session.admit() - } - - #expect(await fixture.controlSend.observedSentBuffers().count == 1) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func serverNatTraversalAuthorizationFailureSendsNoFinalReadyAndCloses() async throws { - let fixture = try ServerFixture(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [fixture.controlStream], - natTraversalAuthorizationError: .natTraversalAuthorizationFailed - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - await #expect(throws: TestIrohTransportError.natTraversalAuthorizationFailed) { - try await session.admit() - } - - #expect(await fixture.controlSend.observedSentBuffers().count == 1) - #expect(await connection.observedNatTraversalAuthorizationAttemptCount() == 1) - #expect(await connection.observedNatTraversalActivationCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func acceptedContextMustMatchTheTLSAuthenticatedPeer() async throws { - let fixture = try ServerFixture(decision: .accepted) - let substitutedPeer = try CmxIrohPeerIdentity( - endpointID: String(repeating: "b", count: 64) - ) - let connection = TestIrohConnection( - remoteIdentity: substitutedPeer, - bidirectionalStreams: [fixture.controlStream] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - await #expect(throws: CmxIrohServerSessionError.admissionDenied(code: 1)) { - try await session.admit() - } - let ack = try #require(await fixture.controlSend.observedSentBuffers().first) - #expect(try CmxIrohAdmissionAckCodec().decodePrefix(ack) == .denied(code: 1)) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func admittedControlUsesTheSharedByteTransportContract() async throws { - let fixture = try ServerFixture(decision: .accepted) - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [fixture.controlStream] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - _ = try await session.admit() - let transport = CmxIrohServerByteTransport(session: session) - - try await transport.connect() - #expect(try await transport.receive() == Data("rpc".utf8)) - try await transport.send(Data("response".utf8)) - await transport.close() - - let buffers = await fixture.controlSend.observedSentBuffers() - #expect(buffers.last == Data("response".utf8)) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func nonControlFirstStreamFailsBeforeAuthorization() async throws { - let fixture = try ServerFixture(decision: .accepted) - let terminalHeader = try fixture.headerCodec.encode( - CmxIrohStreamHeader( - lane: .terminal(resourceID: CmxIrohResourceID("terminal-1"), cursor: nil) - ) - ) - let receive = TestIrohReceiveStream(buffer: terminalHeader) - let send = TestIrohSendStream() - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - CmxIrohBidirectionalStream(receiveStream: receive, sendStream: send), - ] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - - await #expect(throws: CmxIrohServerSessionError.invalidFirstLane) { - try await session.admit() - } - #expect(await fixture.authorizer.callCount() == 0) - #expect(await connection.observedCloseCallCount() == 1) - } - - @Test - func serverSendLaneWritesHeaderBeforePayloadAndSetsPriority() async throws { - let fixture = try ServerFixture(decision: .accepted) - let laneSend = TestIrohSendStream() - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - fixture.controlStream, - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: laneSend - ), - ] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer - ) - try await session.admit() - - let lane = CmxIrohLane.serverEvents(cursor: nil) - let stream = try await session.openSendLane(lane, priority: 42) - try await stream.send(Data("event".utf8)) - let buffers = await laneSend.observedSentBuffers() - let header = try fixture.headerCodec.decodePrefix(try #require(buffers.first)) - #expect(header.header.lane == lane) - #expect(buffers.last == Data("event".utf8)) - #expect(await laneSend.observedPriorities() == [42]) - } - - @Test - func admittedHostValueKeepsControlAndIndependentLanesReachable() async throws { - let fixture = try ServerFixture(decision: .accepted) - let terminalID = try CmxIrohResourceID("terminal-1") - let terminalReceive = TestIrohReceiveStream( - buffer: try fixture.headerCodec.encode( - CmxIrohStreamHeader( - lane: .terminal(resourceID: terminalID, cursor: nil) - ) - ) + Data("terminal".utf8) - ) - let eventSend = TestIrohSendStream() - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - fixture.controlStream, - CmxIrohBidirectionalStream( - receiveStream: terminalReceive, - sendStream: TestIrohSendStream() - ), - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream(buffer: Data()), - sendStream: eventSend - ), - ] - ) - let server = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer, - protocolConfiguration: .testApplicationLanes - ) - let peer = try await server.admit() - let admitted = CmxIrohAdmittedServerSession(peer: peer, session: server) - - try await admitted.controlTransport.connect() - #expect(try await admitted.controlTransport.receive() == Data("rpc".utf8)) - let terminal = try await admitted.acceptBidirectionalLane() - #expect(terminal.lane == .terminal(resourceID: terminalID, cursor: nil)) - #expect( - try await terminal.stream.receiveStream.receive(maximumByteCount: 64) - == Data("terminal".utf8) - ) - let events = try await admitted.openSendLane( - .serverEvents(cursor: 9), - priority: 50 - ) - try await events.send(Data("event".utf8)) - #expect(await eventSend.observedPriorities() == [50]) - #expect(await eventSend.observedSentBuffers().count == 2) - - await admitted.close() - #expect(await connection.observedCloseCallCount() == 1) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTimeoutTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTimeoutTests.swift deleted file mode 100644 index e082ac96..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTimeoutTests.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohServerSessionTests { - @Test - func applicationLaneHeaderTimeoutStopsCancellationIgnoringRead() async throws { - let fixture = try ServerFixture(decision: .accepted) - let stalledReceive = TestBlockingIrohReceiveStream( - buffer: Data(), - cancellationUnblocksReceive: false - ) - let stalledSend = TestIrohSendStream() - let clock = ServerSessionManualClock() - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - fixture.controlStream, - CmxIrohBidirectionalStream( - receiveStream: stalledReceive, - sendStream: stalledSend - ), - ] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer, - protocolConfiguration: .testApplicationLanes, - streamHeaderClock: clock, - streamHeaderTimeout: 1 - ) - _ = try await session.admit() - var blocked = await stalledReceive.blockedEvents().makeAsyncIterator() - let stoppedEvents = await stalledReceive.stoppedEvents() - let accept = Task { try await session.acceptBidirectionalLane() } - _ = await blocked.next() - await clock.waitUntilSleeping() - - await clock.fire() - let stoppedCode = await firstStopCode(in: stoppedEvents, timeout: .seconds(1)) - - await #expect(throws: CmxIrohServerSessionError.applicationLaneRejected) { - try await accept.value - } - #expect(stoppedCode == 1) - #expect(await stalledSend.observedResetCodes() == [1]) - } - - @Test - func rejectedApplicationLaneDoesNotConsumeTheNextValidLane() async throws { - let fixture = try ServerFixture(decision: .accepted) - let stalledReceive = TestBlockingIrohReceiveStream( - buffer: Data(), - cancellationUnblocksReceive: false - ) - let stalledSend = TestIrohSendStream() - let terminalID = try CmxIrohResourceID("terminal:recovered") - let validHeader = try fixture.headerCodec.encode( - CmxIrohStreamHeader( - lane: .terminal(resourceID: terminalID, cursor: 42) - ) - ) - let clock = ServerSessionManualClock() - let connection = TestIrohConnection( - remoteIdentity: fixture.peerID, - bidirectionalStreams: [ - fixture.controlStream, - CmxIrohBidirectionalStream( - receiveStream: stalledReceive, - sendStream: stalledSend - ), - CmxIrohBidirectionalStream( - receiveStream: TestIrohReceiveStream( - buffer: validHeader + Data("payload".utf8) - ), - sendStream: TestIrohSendStream() - ), - ] - ) - let session = try CmxIrohServerSession( - connection: connection, - authorizer: fixture.authorizer, - protocolConfiguration: .testApplicationLanes, - streamHeaderClock: clock, - streamHeaderTimeout: 1 - ) - _ = try await session.admit() - var blocked = await stalledReceive.blockedEvents().makeAsyncIterator() - let stoppedEvents = await stalledReceive.stoppedEvents() - let rejected = Task { try await session.acceptBidirectionalLane() } - _ = await blocked.next() - await clock.waitUntilSleeping() - - await clock.fire() - let stoppedCode = await firstStopCode(in: stoppedEvents, timeout: .seconds(1)) - await #expect(throws: CmxIrohServerSessionError.applicationLaneRejected) { - try await rejected.value - } - #expect(stoppedCode == 1) - - let accepted = try await session.acceptBidirectionalLane() - #expect(accepted.lane == .terminal(resourceID: terminalID, cursor: 42)) - #expect( - try await accepted.stream.receiveStream.receive(maximumByteCount: 64) - == Data("payload".utf8) - ) - #expect(await connection.observedCloseCallCount() == 0) - } -} - -private func firstStopCode( - in events: AsyncStream<UInt64>, - timeout: Duration -) async -> UInt64? { - await withTaskGroup(of: UInt64?.self) { group in - group.addTask { - var iterator = events.makeAsyncIterator() - return await iterator.next() - } - group.addTask { - do { - try await ContinuousClock().sleep(for: timeout) - } catch { - return nil - } - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift deleted file mode 100644 index 562a793f..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohStreamHeaderCodecTests.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohStreamHeaderCodecTests { - @Test - func pairGrantControlRoundTripsWithoutConsumingApplicationBytes() throws { - let codec = try CmxIrohStreamHeaderCodec() - let credential = try CmxIrohAdmissionCredential.pairGrant("e30.e30.AA") - let header = try CmxIrohStreamHeader(lane: .control, credential: credential) - let encoded = try codec.encode(header) - let applicationBytes = Data([0xde, 0xad, 0xbe, 0xef]) - - let decoded = try codec.decodePrefix(encoded + applicationBytes) - - #expect(decoded.header == header) - #expect(decoded.consumedByteCount == encoded.count) - #expect((encoded + applicationBytes).dropFirst(decoded.consumedByteCount) == applicationBytes) - } - - @Test - func offlinePairingControlRoundTripsAttestationInvitationAndProof() throws { - let codec = try CmxIrohStreamHeaderCodec() - let invitationID = try CmxIrohResourceID("invite:42") - let credential = try CmxIrohAdmissionCredential.offlinePairing( - endpointAttestation: "eyJraWQiOiJrMSJ9.e30.AA", - invitationID: invitationID, - proof: Data(repeating: 0x5a, count: 32) - ) - let header = try CmxIrohStreamHeader(lane: .control, credential: credential) - - #expect(try codec.decodePrefix(codec.encode(header)).header == header) - } - - @Test - func multistreamLanesRoundTrip() throws { - let codec = try CmxIrohStreamHeaderCodec() - let terminalID = try CmxIrohResourceID("terminal:1") - let artifactID = try CmxIrohResourceID("artifact.preview:2") - let lanes: [CmxIrohLane] = [ - .serverEvents(cursor: nil), - .serverEvents(cursor: 91), - .terminal(resourceID: terminalID, cursor: nil), - .terminal(resourceID: terminalID, cursor: 4_096), - .artifact(resourceID: artifactID, offset: 8_192), - ] - - for lane in lanes { - let header = try CmxIrohStreamHeader(lane: lane) - #expect(try codec.decodePrefix(codec.encode(header)).header == header) - } - } - - @Test - func controlRequiresCredentialAndOtherLanesRejectIt() throws { - #expect(throws: CmxIrohStreamHeaderError.missingControlCredential) { - try CmxIrohStreamHeader(lane: .control) - } - - let credential = try CmxIrohAdmissionCredential.pairGrant("e30.e30.AA") - #expect(throws: CmxIrohStreamHeaderError.credentialOnNonControlLane) { - try CmxIrohStreamHeader( - lane: .serverEvents(cursor: nil), - credential: credential - ) - } - } - - @Test - func incompleteFrameReportsTheExactNextRequiredLength() throws { - let codec = try CmxIrohStreamHeaderCodec() - let header = try CmxIrohStreamHeader( - lane: .control, - credential: .pairGrant("e30.e30.AA") - ) - let encoded = try codec.encode(header) - - #expect(throws: CmxIrohStreamHeaderCodecError.incompleteFrame(requiredByteCount: 16)) { - try codec.decodePrefix(encoded.prefix(15)) - } - #expect(throws: CmxIrohStreamHeaderCodecError.incompleteFrame(requiredByteCount: encoded.count)) { - try codec.decodePrefix(encoded.dropLast()) - } - } - - @Test - func malformedPrefixAndReservedFieldsFailClosed() throws { - let codec = try CmxIrohStreamHeaderCodec() - let terminal = try CmxIrohStreamHeader( - lane: .terminal(resourceID: CmxIrohResourceID("terminal:1"), cursor: nil) - ) - let baseline = try codec.encode(terminal) - - var invalidMagic = baseline - invalidMagic[invalidMagic.startIndex] ^= 0xff - #expect(throws: CmxIrohStreamHeaderCodecError.invalidMagic) { - try codec.decodePrefix(invalidMagic) - } - - var invalidVersion = baseline - invalidVersion[invalidVersion.startIndex + 8] = 2 - #expect(throws: CmxIrohStreamHeaderCodecError.unsupportedVersion(2)) { - try codec.decodePrefix(invalidVersion) - } - - var unknownLane = baseline - unknownLane[unknownLane.startIndex + 9] = 99 - #expect(throws: CmxIrohStreamHeaderCodecError.unknownLane(99)) { - try codec.decodePrefix(unknownLane) - } - - var reservedFlags = baseline - reservedFlags[reservedFlags.startIndex + 10] = 0x80 - #expect(throws: CmxIrohStreamHeaderCodecError.invalidFlags(0x80)) { - try codec.decodePrefix(reservedFlags) - } - - var secondCredential = baseline - secondCredential[secondCredential.startIndex + 11] = 1 - #expect(throws: CmxIrohStreamHeaderCodecError.invalidCredentialKind(1)) { - try codec.decodePrefix(secondCredential) - } - } - - @Test - func declaredOversizeHeaderIsRejectedBeforeBufferingPayload() throws { - let codec = try CmxIrohStreamHeaderCodec( - configuration: CmxIrohProtocolConfiguration( - alpn: Data("test".utf8), - maximumHeaderByteCount: 32 - ) - ) - var prefix = Data("CMUXIRH1".utf8) - prefix.append(contentsOf: [1, 1, 0, 1, 0, 0, 1, 0]) - - #expect(throws: CmxIrohStreamHeaderCodecError.headerTooLarge(272)) { - try codec.decodePrefix(prefix) - } - } - - @Test - func credentialAndResourceValidationRejectsAmbiguousValues() throws { - #expect(throws: CmxIrohAdmissionCredentialError.invalidSignedToken) { - try CmxIrohAdmissionCredential.pairGrant("not-a-jws") - } - #expect(throws: CmxIrohAdmissionCredentialError.invalidOfflineProofLength(31)) { - try CmxIrohAdmissionCredential.offlinePairing( - endpointAttestation: "e30.e30.AA", - invitationID: CmxIrohResourceID("invite"), - proof: Data(repeating: 0, count: 31) - ) - } - #expect(throws: CmxIrohResourceIDError.invalidValue) { - try CmxIrohResourceID("device name") - } - } - - @Test - func payloadMustBeConsumedExactly() throws { - let codec = try CmxIrohStreamHeaderCodec() - let header = try CmxIrohStreamHeader(lane: .serverEvents(cursor: nil)) - var frame = try codec.encode(header) - frame[frame.startIndex + 15] = 1 - frame.append(0) - - #expect(throws: CmxIrohStreamHeaderCodecError.invalidPayload) { - try codec.decodePrefix(frame) - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSystemBonjourBrowserTestSupport.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSystemBonjourBrowserTestSupport.swift deleted file mode 100644 index 981ad7dd..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSystemBonjourBrowserTestSupport.swift +++ /dev/null @@ -1,294 +0,0 @@ -import Foundation -import dnssd -@testable import CmuxIrohTransport - -final class TestBonjourOperation: CmxIrohBonjourOperation, Sendable { - private let onCancel: @Sendable () -> Void - - init(onCancel: @escaping @Sendable () -> Void) { - self.onCancel = onCancel - } - - func cancel() { - onCancel() - } -} -final class TestBonjourDNSService: CmxIrohBonjourDNSService, @unchecked Sendable { - struct Snapshot: Sendable { - let resolveStarts: [String] - let activeResolveCount: Int - let maximumActiveResolveCount: Int - let resolveCancellationCount: Int - let browseCancellationCount: Int - } - - private struct ResolveEntry { - let serviceName: String - let handler: CmxIrohBonjourResolveHandler - } - - private let lock = NSLock() - private var browseHandler: CmxIrohBonjourBrowseHandler? - private var browseOperationID: UUID? - private var resolves: [UUID: ResolveEntry] = [:] - private var resolveStarts: [String] = [] - private var maximumActiveResolveCount = 0 - private var cancelledResolveIDs: Set<UUID> = [] - private var browseCancellationCount = 0 - private var cancellationWaiters: [CheckedContinuation<Void, Never>] = [] - private var resolveStartWaiters: [CheckedContinuation<Void, Never>] = [] - - func startBrowse( - serviceType _: String, - domain _: String, - handler: @escaping CmxIrohBonjourBrowseHandler - ) throws -> any CmxIrohBonjourOperation { - let id = UUID() - lock.withLock { - browseHandler = handler - browseOperationID = id - } - return TestBonjourOperation { [weak self] in - self?.cancelBrowse(id: id) - } - } - - func startResolve( - id: CmxIrohBonjourServiceID, - regtype _: String, - domain _: String, - handler: @escaping CmxIrohBonjourResolveHandler - ) throws -> any CmxIrohBonjourOperation { - let operationID = UUID() - let waiters: [CheckedContinuation<Void, Never>] = lock.withLock { - resolveStarts.append(id.serviceName) - resolves[operationID] = ResolveEntry( - serviceName: id.serviceName, - handler: handler - ) - maximumActiveResolveCount = max(maximumActiveResolveCount, resolves.count) - let waiters = resolveStartWaiters - resolveStartWaiters.removeAll(keepingCapacity: false) - return waiters - } - for waiter in waiters { waiter.resume() } - return TestBonjourOperation { [weak self] in - self?.cancelResolve(id: operationID) - } - } - - func emitAdded(serviceName: String, interfaceIndex: UInt32 = 4) async { - let handler = lock.withLock { browseHandler } - handler?( - DNSServiceFlags(kDNSServiceFlagsAdd), - interfaceIndex, - Int32(kDNSServiceErr_NoError), - serviceName, - "\(CmxIrohLANAdvertisement.serviceType).", - CmxIrohLANAdvertisement.domain - ) - } - - func emitResolved(serviceName: String, interfaceIndex: UInt32 = 4) async { - let handler = lock.withLock { - resolves.values.first(where: { $0.serviceName == serviceName })?.handler - } - await handler?( - Int32(kDNSServiceErr_NoError), - interfaceIndex, - "h-\(serviceName).local.", - 50_906, - Data() - ) - } - - func snapshot() -> Snapshot { - lock.withLock { - Snapshot( - resolveStarts: resolveStarts, - activeResolveCount: resolves.count, - maximumActiveResolveCount: maximumActiveResolveCount, - resolveCancellationCount: cancelledResolveIDs.count, - browseCancellationCount: browseCancellationCount - ) - } - } - - func waitForResolveCancellationCount(_ expectedCount: Int) async { - while snapshot().resolveCancellationCount < expectedCount { - await withCheckedContinuation { continuation in - let resumeImmediately = lock.withLock { - if cancelledResolveIDs.count >= expectedCount { return true } - cancellationWaiters.append(continuation) - return false - } - if resumeImmediately { continuation.resume() } - } - } - } - - func waitForResolveStartCount(_ expectedCount: Int) async { - while snapshot().resolveStarts.count < expectedCount { - await withCheckedContinuation { continuation in - let resumeImmediately = lock.withLock { - if resolveStarts.count >= expectedCount { return true } - resolveStartWaiters.append(continuation) - return false - } - if resumeImmediately { continuation.resume() } - } - } - } - - private func cancelBrowse(id: UUID) { - lock.withLock { - guard browseOperationID == id else { return } - browseOperationID = nil - browseHandler = nil - browseCancellationCount += 1 - } - } - - private func cancelResolve(id: UUID) { - let waiters: [CheckedContinuation<Void, Never>] = lock.withLock { - guard resolves.removeValue(forKey: id) != nil, - cancelledResolveIDs.insert(id).inserted else { return [] } - let waiters = cancellationWaiters - cancellationWaiters.removeAll(keepingCapacity: false) - return waiters - } - for waiter in waiters { waiter.resume() } - } -} - -struct TestBonjourClock: CmxIrohBonjourClock { - private let fixedNow: Date - private let state = TestBonjourClockState() - - init(now: Date) { - fixedNow = now - } - - func now() -> Date { fixedNow } - - func sleep(until deadline: Date) async throws { - try await state.sleep(until: deadline) - } - - func advance(by interval: TimeInterval) async { - await state.advance(to: fixedNow.addingTimeInterval(interval)) - } - - func pendingSleepCount() async -> Int { - await state.pendingSleepCount() - } - - func waitForPendingSleepCount(_ expectedCount: Int) async { - await state.waitForPendingSleepCount(expectedCount) - } - - func waitUntilIdle() async { - await state.waitUntilIdle() - } -} - -actor TestBonjourClockState { - private struct Sleeper { - let deadline: Date - let continuation: CheckedContinuation<Void, any Error> - } - - private struct CountWaiter { - let expectedCount: Int - let continuation: CheckedContinuation<Void, Never> - } - - private var sleepers: [UUID: Sleeper] = [:] - private var countWaiters: [UUID: CountWaiter] = [:] - private var idleWaiters: [UUID: CheckedContinuation<Void, Never>] = [:] - - func sleep(until deadline: Date) async throws { - let id = UUID() - try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - sleepers[id] = Sleeper(deadline: deadline, continuation: continuation) - resumeCountWaitersIfNeeded() - } - } onCancel: { - Task { await self.cancel(id: id) } - } - } - - func advance(to date: Date) { - let ready = sleepers.filter { $0.value.deadline <= date } - for id in ready.keys { sleepers[id] = nil } - for sleeper in ready.values { sleeper.continuation.resume() } - resumeIdleWaitersIfNeeded() - } - - func pendingSleepCount() -> Int { sleepers.count } - - func waitForPendingSleepCount(_ expectedCount: Int) async { - guard sleepers.count < expectedCount else { return } - let id = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled || sleepers.count >= expectedCount { - continuation.resume() - } else { - countWaiters[id] = CountWaiter( - expectedCount: expectedCount, - continuation: continuation - ) - } - } - } onCancel: { - Task { await self.cancelCountWaiter(id) } - } - } - - func waitUntilIdle() async { - guard !sleepers.isEmpty else { return } - let id = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled || sleepers.isEmpty { - continuation.resume() - } else { - idleWaiters[id] = continuation - } - } - } onCancel: { - Task { await self.cancelIdleWaiter(id) } - } - } - - private func cancel(id: UUID) { - guard let sleeper = sleepers.removeValue(forKey: id) else { return } - sleeper.continuation.resume(throwing: CancellationError()) - resumeIdleWaitersIfNeeded() - } - - private func resumeIdleWaitersIfNeeded() { - guard sleepers.isEmpty else { return } - let waiters = idleWaiters.values - idleWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - } - - private func resumeCountWaitersIfNeeded() { - let readyIDs = countWaiters.compactMap { id, waiter in - sleepers.count >= waiter.expectedCount ? id : nil - } - let ready = readyIDs.compactMap { countWaiters.removeValue(forKey: $0) } - for waiter in ready { waiter.continuation.resume() } - } - - private func cancelCountWaiter(_ id: UUID) { - countWaiters.removeValue(forKey: id)?.continuation.resume() - } - - private func cancelIdleWaiter(_ id: UUID) { - idleWaiters.removeValue(forKey: id)?.resume() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSystemBonjourBrowserTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSystemBonjourBrowserTests.swift deleted file mode 100644 index 660db1bc..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohSystemBonjourBrowserTests.swift +++ /dev/null @@ -1,228 +0,0 @@ -import Foundation -import Testing -import dnssd -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohSystemBonjourBrowserTests { - @Test - func hostileBrowseFloodIsBoundedAndAliasesAreValidatedBeforeResolve() async throws { - let dnsService = TestBonjourDNSService() - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let browser = CmxIrohSystemBonjourBrowser( - dnsService: dnsService, - clock: clock, - maximumPendingResolves: 2, - resolveTimeout: 5 - ) - let stream = await browser.events() - let observationTask = Task { - for await _ in stream {} - } - - for alias in [ - "short", - String(repeating: "A", count: 32), - String(repeating: "g", count: 32), - String(repeating: "0", count: 31) + "-", - ] { - await dnsService.emitAdded(serviceName: alias) - } - for alias in canonicalAliases(count: 20) { - await dnsService.emitAdded(serviceName: alias) - } - await dnsService.waitForResolveStartCount(2) - - let snapshot = dnsService.snapshot() - #expect(snapshot.resolveStarts.count == 2) - #expect(snapshot.maximumActiveResolveCount == 2) - #expect(snapshot.resolveStarts.allSatisfy( - CmxIrohLANRendezvousAliasGenerator.isCanonicalAlias - )) - - await browser.stop() - await observationTask.value - } - - @Test - func unresolvedOperationExpiresAndFreesCapacityForAnotherAlias() async throws { - let dnsService = TestBonjourDNSService() - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let browser = CmxIrohSystemBonjourBrowser( - dnsService: dnsService, - clock: clock, - maximumPendingResolves: 1, - resolveTimeout: 5 - ) - let stream = await browser.events() - let observationTask = Task { - for await _ in stream {} - } - let aliases = canonicalAliases(count: 2) - - await dnsService.emitAdded(serviceName: aliases[0]) - await clock.waitForPendingSleepCount(1) - - await clock.advance(by: 5) - await dnsService.waitForResolveCancellationCount(1) - #expect(await clock.pendingSleepCount() == 0) - - await dnsService.emitAdded(serviceName: aliases[1]) - await dnsService.waitForResolveStartCount(2) - let snapshot = dnsService.snapshot() - #expect(snapshot.resolveStarts == aliases) - #expect(snapshot.activeResolveCount == 1) - #expect(snapshot.maximumActiveResolveCount == 1) - - await browser.stop() - await observationTask.value - } - - @Test - func queuedServiceStartsWhenActiveResolveCompletes() async throws { - let dnsService = TestBonjourDNSService() - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let browser = CmxIrohSystemBonjourBrowser( - dnsService: dnsService, - clock: clock, - maximumPendingResolves: 1, - resolveTimeout: 5 - ) - let stream = await browser.events() - let observationTask = Task { - for await _ in stream {} - } - let aliases = canonicalAliases(count: 2) - - await dnsService.emitAdded(serviceName: aliases[0]) - await dnsService.emitAdded(serviceName: aliases[1]) - await dnsService.waitForResolveStartCount(1) - #expect(dnsService.snapshot().resolveStarts == [aliases[0]]) - - await dnsService.emitResolved(serviceName: aliases[0]) - await dnsService.waitForResolveStartCount(2) - - #expect(dnsService.snapshot().resolveStarts == aliases) - #expect(dnsService.snapshot().maximumActiveResolveCount == 1) - - await browser.stop() - await observationTask.value - } - - @Test - func canonicalServiceStillResolvesAfterGarbageBrowseEvents() async throws { - let dnsService = TestBonjourDNSService() - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let browser = CmxIrohSystemBonjourBrowser( - dnsService: dnsService, - clock: clock, - maximumPendingResolves: 1, - resolveTimeout: 5 - ) - let stream = await browser.events() - var iterator = stream.makeAsyncIterator() - - for index in 0 ..< 100 { - await dnsService.emitAdded(serviceName: "hostile-\(index)") - } - let alias = String(repeating: "a", count: 32) - await dnsService.emitAdded(serviceName: alias) - await clock.waitForPendingSleepCount(1) - await dnsService.emitResolved(serviceName: alias) - - guard case let .resolved(id, service) = await iterator.next() else { - Issue.record("Expected the canonical service to resolve") - await browser.stop() - return - } - #expect(id.serviceName == alias) - #expect(service.serviceName == alias) - #expect(dnsService.snapshot().resolveStarts == [alias]) - await clock.waitUntilIdle() - #expect(await clock.pendingSleepCount() == 0) - - await browser.stop() - } - - @Test - func stopCancelsBrowseResolvesAndEveryDeadline() async throws { - let dnsService = TestBonjourDNSService() - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let browser = CmxIrohSystemBonjourBrowser( - dnsService: dnsService, - clock: clock, - maximumPendingResolves: 3, - resolveTimeout: 5 - ) - let stream = await browser.events() - let aliases = canonicalAliases(count: 3) - for alias in aliases { - await dnsService.emitAdded(serviceName: alias) - } - await clock.waitForPendingSleepCount(3) - - await browser.stop() - await dnsService.waitForResolveCancellationCount(3) - await clock.waitUntilIdle() - - let snapshot = dnsService.snapshot() - #expect(snapshot.browseCancellationCount == 1) - #expect(snapshot.resolveCancellationCount == 3) - #expect(snapshot.activeResolveCount == 0) - #expect(await clock.pendingSleepCount() == 0) - - await dnsService.emitAdded(serviceName: String(repeating: "f", count: 32)) - #expect(dnsService.snapshot().resolveStarts == aliases) - - var iterator = stream.makeAsyncIterator() - #expect(await iterator.next() == nil) - } - - @Test - func cancellingLastObservationCancelsBrowseResolveAndDeadline() async throws { - let dnsService = TestBonjourDNSService() - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let browser = CmxIrohSystemBonjourBrowser( - dnsService: dnsService, - clock: clock, - maximumPendingResolves: 1, - resolveTimeout: 5 - ) - let stream = await browser.events() - let observationTask = Task { - for await _ in stream {} - } - await dnsService.emitAdded(serviceName: String(repeating: "a", count: 32)) - await clock.waitForPendingSleepCount(1) - - observationTask.cancel() - await observationTask.value - await dnsService.waitForResolveCancellationCount(1) - await clock.waitUntilIdle() - - let snapshot = dnsService.snapshot() - #expect(snapshot.browseCancellationCount == 1) - #expect(snapshot.resolveCancellationCount == 1) - #expect(snapshot.activeResolveCount == 0) - #expect(await clock.pendingSleepCount() == 0) - } - - @Test - func cancellingClockWaitReleasesItsContinuation() async { - let clock = TestBonjourClock(now: Date(timeIntervalSince1970: 1_800_000_000)) - let waiter = Task { - await clock.waitForPendingSleepCount(1) - } - - waiter.cancel() - await waiter.value - - #expect(await clock.pendingSleepCount() == 0) - } - - private func canonicalAliases(count: Int) -> [String] { - (0 ..< count).map { index in - String(repeating: "0", count: 24) + String(format: "%08x", index) - } - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTerminalOutputEnvelopeTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTerminalOutputEnvelopeTests.swift deleted file mode 100644 index 5d64de07..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTerminalOutputEnvelopeTests.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite -struct CmxIrohTerminalOutputEnvelopeTests { - @Test - func fragmentedReplayAndLiveChunksRoundTripWithExactSequences() throws { - let replay = try CmxIrohTerminalOutputEnvelope( - kind: .replay, - retainedBaseSequence: 40, - sequence: 42, - currentSequence: 45, - payload: Data("abc".utf8) - ) - let chunk = try CmxIrohTerminalOutputEnvelope( - kind: .chunk, - retainedBaseSequence: 45, - sequence: 45, - currentSequence: 48, - payload: Data("def".utf8) - ) - let codec = CmxIrohTerminalOutputEnvelopeCodec() - let bytes = codec.encode(replay) + codec.encode(chunk) - var decoder = CmxIrohTerminalOutputEnvelopeDecoder() - var decoded: [CmxIrohTerminalOutputEnvelope] = [] - - for byte in bytes { - decoded.append(contentsOf: try decoder.append(Data([byte]))) - } - - #expect(decoded == [replay, chunk]) - #expect(!decoder.hasBufferedBytes) - } - - @Test - func sequenceAndPayloadMismatchIsRejectedBeforeEncoding() { - #expect(throws: CmxIrohTerminalOutputEnvelope.ValidationError.invalidSequenceRange) { - try CmxIrohTerminalOutputEnvelope( - kind: .chunk, - retainedBaseSequence: 11, - sequence: 10, - currentSequence: 11, - payload: Data([1]) - ) - } - #expect(throws: CmxIrohTerminalOutputEnvelope.ValidationError.payloadLengthMismatch( - expected: 2, - actual: 1 - )) { - try CmxIrohTerminalOutputEnvelope( - kind: .chunk, - retainedBaseSequence: 10, - sequence: 10, - currentSequence: 12, - payload: Data([1]) - ) - } - } - - @Test - func decoderRetainsAnIncompleteFrameWithoutEmittingPartialBytes() throws { - let envelope = try CmxIrohTerminalOutputEnvelope( - kind: .replay, - retainedBaseSequence: 0, - sequence: 0, - currentSequence: 6, - payload: Data("output".utf8) - ) - let encoded = CmxIrohTerminalOutputEnvelopeCodec().encode(envelope) - var decoder = CmxIrohTerminalOutputEnvelopeDecoder() - - #expect(try decoder.append(encoded.dropLast()).isEmpty) - #expect(decoder.hasBufferedBytes) - #expect(try decoder.append(Data(encoded.suffix(1))) == [envelope]) - #expect(!decoder.hasBufferedBytes) - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift deleted file mode 100644 index 07e5b5a9..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientNetworkTests.swift +++ /dev/null @@ -1,134 +0,0 @@ -import Foundation -import Testing -@testable import CmuxIrohTransport - -extension CmxIrohTrustBrokerClientTests { - @Test - func rateLimitRetainsOnlyBoundedCanonicalRetryAfterSeconds() async throws { - for (header, expected) in [ - ("600", CmxIrohTrustBrokerClientError.rateLimited( - code: "rate_limited", - retryAfterSeconds: 600 - )), - ("0", CmxIrohTrustBrokerClientError.rejected( - statusCode: 429, - code: "rate_limited" - )), - ("3601", CmxIrohTrustBrokerClientError.rejected( - statusCode: 429, - code: "rate_limited" - )), - ("0600", CmxIrohTrustBrokerClientError.rejected( - statusCode: 429, - code: "rate_limited" - )), - ] { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 429, - body: #"{"error":"rate_limited","token":"do-not-copy"}"#, - headers: ["Retry-After": header] - ), - ]) - let client = try makeNetworkClient(transport: transport) - - await #expect(throws: expected) { - _ = try await client.discover() - } - } - } - - @Test - func missingAuthFailsBeforeAnyNetworkRequest() async throws { - let transport = RecordingBrokerTransport(responses: []) - let client = try CmxIrohTrustBrokerClient( - baseURL: try #require(URL(string: "https://cmux.example")), - tokenSource: CmxIrohBrokerTokenSource( - accessToken: { nil }, - refreshToken: { "refresh" } - ), - transport: transport - ) - await #expect(throws: CmxIrohTrustBrokerClientError.missingAuthentication) { - _ = try await client.discover() - } - #expect(await transport.requests().isEmpty) - } - - @Test - func cleartextRemoteOriginIsRejected() throws { - #expect(throws: CmxIrohTrustBrokerClientError.invalidBaseURL) { - _ = try CmxIrohTrustBrokerClient( - baseURL: #require(URL(string: "http://cmux.example")), - tokenSource: Self.networkTokenSource, - transport: RecordingBrokerTransport(responses: []) - ) - } - } - - @Test - func availabilityURLErrorMapsToConnectivityFailure() async throws { - let transport = RecordingBrokerTransport( - responses: [], - failure: .notConnectedToInternet - ) - let client = try makeNetworkClient(transport: transport) - - await #expect(throws: CmxIrohTrustBrokerClientError.connectivity) { - _ = try await client.discover() - } - } - - @Test - func tlsValidationURLErrorRemainsTerminal() async throws { - let transport = RecordingBrokerTransport( - responses: [], - failure: .serverCertificateUntrusted - ) - let client = try makeNetworkClient(transport: transport) - - do { - _ = try await client.discover() - Issue.record("Expected TLS validation failure") - } catch let error as URLError { - #expect(error.code == .serverCertificateUntrusted) - } - } - - @Test - func redirectsNeverForwardBrokerCredentials() async throws { - for destination in [ - try #require(URL(string: "https://cmux.example/capture")), - try #require(URL(string: "https://attacker.example/capture")), - ] { - BrokerRedirectURLProtocol.reset(destination: destination) - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [BrokerRedirectURLProtocol.self] - let client = try CmxIrohTrustBrokerClient( - baseURL: try #require(URL(string: "https://cmux.example")), - tokenSource: Self.networkTokenSource, - transport: CmxIrohURLSessionTransport(configuration: configuration), - requestTimeout: 0.1 - ) - - _ = try? await client.discover() - - #expect(BrokerRedirectURLProtocol.capturedDestinationRequests().isEmpty) - } - } - - private func makeNetworkClient( - transport: RecordingBrokerTransport - ) throws -> CmxIrohTrustBrokerClient { - try CmxIrohTrustBrokerClient( - baseURL: #require(URL(string: "https://cmux.example")), - tokenSource: Self.networkTokenSource, - transport: transport - ) - } - - private static let networkTokenSource = CmxIrohBrokerTokenSource( - accessToken: { "access" }, - refreshToken: { "refresh" } - ) -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift deleted file mode 100644 index c52970a0..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +++ /dev/null @@ -1,542 +0,0 @@ -import CMUXMobileCore -import Foundation -import Testing -@testable import CmuxIrohTransport - -@Suite(.serialized) -struct CmxIrohTrustBrokerClientTests { - @Test - func challengeUsesNativeStackHeadersAndExactJSON() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 201, - body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"# - ), - ]) - let client = try makeClient(transport: transport) - let payload = try registrationPayload() - let signer = try registrationSigner() - let request = try signer.prepare(payload: payload).challengeRequest - - let response = try await client.issueChallenge(request) - #expect(response.challengeID == "123e4567-e89b-42d3-a456-426614174000") - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/devices/iroh/challenge") - #expect(captured.httpMethod == "POST") - #expect(captured.value(forHTTPHeaderField: "Authorization") == "Bearer access") - #expect(captured.value(forHTTPHeaderField: "X-Stack-Refresh-Token") == "refresh") - let body = try #require(captured.httpBody) - let object = try #require( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(object["endpointId"] as? String == Self.endpointID) - #expect(object["identityGeneration"] as? Int == 1) - } - - @Test - func issuedRegistrationBuildsTheExactManagedRelayFleet() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json(status: 201, body: Self.registrationResponse), - ]) - let client = try makeClient(transport: transport) - let response = try await client.register( - CmxIrohRegisterRequest( - challengeID: "123e4567-e89b-42d3-a456-426614174000", - nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - payload: "e30", - signature: String(repeating: "A", count: 86) - ) - ) - guard case let .issued(relay) = response.relay else { - Issue.record("Expected an issued relay credential") - return - } - let now = try #require(ISO8601DateFormatter().date(from: "2026-07-10T00:00:00Z")) - let configurations = try relay.relayConfigurations(now: now) - #expect(configurations.map(\.url) == Self.relayURLs) - #expect(configurations.allSatisfy { $0.token == "abc234" }) - } - - @Test - func existingRegistrationAcceptsNotRequestedRelayBootstrap() async throws { - var responseObject = try #require( - JSONSerialization.jsonObject( - with: Data(Self.registrationResponse.utf8) - ) as? [String: Any] - ) - responseObject["relay"] = ["status": "not_requested"] - let responseData = try JSONSerialization.data(withJSONObject: responseObject) - let responseBody = try #require(String(data: responseData, encoding: .utf8)) - let transport = RecordingBrokerTransport(responses: [ - .json(status: 201, body: responseBody), - ]) - let client = try makeClient(transport: transport) - - let response = try await client.register( - CmxIrohRegisterRequest( - challengeID: "123e4567-e89b-42d3-a456-426614174000", - nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - payload: "e30", - signature: String(repeating: "A", count: 86) - ) - ) - - #expect(response.binding.tag == "stable") - #expect(response.relay == .notRequested) - } - - @Test - func relayTokenBindsCanonicalHexEndpointAndNormalizesFleetOrigins() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(Self.relayJWT)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://usc1.relay.cmux.dev","https://euw4.relay.cmux.dev/"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - let response = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - - #expect(response.relayFleet == [ - "https://usc1.relay.cmux.dev/", - "https://euw4.relay.cmux.dev/", - ]) - let configurations = try response.relayConfigurations( - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - #expect(configurations.count == 2) - #expect(configurations.allSatisfy { - $0.token == Self.relayJWT - }) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/relay/token") - let body = try #require(captured.httpBody) - let object = try #require( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(object.count == 1) - #expect(object["endpointId"] as? String == Self.endpointID) - } - - @Test - func relayTokenPreservesDistinctCredentialsForEachServerDrivenRelay() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "endpointId":"\(Self.endpointID)", - "relayCredentials":[ - { - "relayUrl":"https://usc1.relay.cmux.dev", - "token":"abc234", - "expiresAt":1782000300, - "refreshAfter":1782000240, - "ttlSeconds":300 - }, - { - "relayUrl":"https://relay.other.example/", - "token":"def567", - "expiresAt":1782000360, - "refreshAfter":1782000240, - "ttlSeconds":360 - } - ] - } - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - let response = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - - #expect(response.relayFleet == [ - "https://usc1.relay.cmux.dev/", - "https://relay.other.example/", - ]) - let configurations = try response.relayConfigurations( - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - #expect(configurations.map(\.token) == ["abc234", "def567"]) - #expect(configurations[0].expiresAt != configurations[1].expiresAt) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/relay/token") - } - - @Test - func relayTokenRejectsCredentialAssociationForAnotherEndpoint() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - { - "endpointId":"\(String(repeating: "f", count: 64))", - "relayCredentials":[{ - "relayUrl":"https://usc1.relay.cmux.dev/", - "token":"abc234", - "expiresAt":1782000300, - "refreshAfter":1782000240, - "ttlSeconds":300 - }] - } - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsCredentialCatalogAboveBound() async throws { - let credentials = (1 ... CmxIrohRelayPolicyVerifier.maximumRelayCount + 1) - .map { index in - """ - {"relayUrl":"https://relay-\(index).example/","token":"abc234","expiresAt":1782000300,"refreshAfter":1782000240,"ttlSeconds":300} - """ - } - .joined(separator: ",") - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"endpointId":"\(Self.endpointID)","relayCredentials":[\(credentials)]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsNonOriginFleetURL() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(Self.relayJWT)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://relay.cmux.dev/capture"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func relayTokenRejectsJWTBoundToAnotherEndpoint() async throws { - let substituted = Self.makeRelayJWT(endpointID: String(repeating: "f", count: 64)) - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: """ - {"token":"\(substituted)","expiresAt":1782000300,"ttlSeconds":300,"relays":["https://usc1.relay.cmux.dev"]} - """ - ), - ]) - let client = try makeClient(transport: transport) - let endpointID = try CmxIrohPeerIdentity(endpointID: Self.endpointID) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.issueRelayToken( - bindingID: Self.bindingID, - endpointID: endpointID - ) - } - } - - @Test - func revokeUsesTheBrokerDeleteRoute() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json( - status: 200, - body: #"{"revoked":true,"lan_rendezvous_rotated":true}"# - ), - ]) - let client = try makeClient(transport: transport) - let bindingID = "123e4567-e89b-42d3-a456-426614174010" - - try await client.revoke(bindingID: bindingID) - - let captured = try #require(await transport.requests().first) - #expect(captured.url?.path == "/api/devices/iroh") - #expect(captured.httpMethod == "DELETE") - let body = try #require(captured.httpBody) - let object = try #require( - JSONSerialization.jsonObject(with: body) as? [String: Any] - ) - #expect(object["bindingId"] as? String == bindingID) - } - - @Test - func discoveryDecodesBrokerISO8601PathHintDates() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json(status: 200, body: Self.discoveryResponse), - ]) - let client = try makeClient(transport: transport) - - let discovery = try await client.discover() - - let binding = try #require(discovery.bindings.first) - let hint = try #require(binding.pathHints.first) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - #expect(hint.value == Self.relayURLs[0]) - #expect( - hint.observedAt - == formatter.date(from: "2026-07-10T00:00:00.000Z") - ) - #expect( - hint.expiresAt - == formatter.date(from: "2026-07-10T01:00:00.000Z") - ) - } - - @Test - func discoveryAcceptsCombinedManagedFleetAboveLegacyLimit() async throws { - var object = try #require( - JSONSerialization.jsonObject( - with: Data(Self.discoveryResponse.utf8) - ) as? [String: Any] - ) - let relayFleet = (1 ... 11).map { "https://relay-\($0).example.com/" } - object["relay_fleet"] = relayFleet - let data = try JSONSerialization.data(withJSONObject: object) - let body = try #require(String(data: data, encoding: .utf8)) - let transport = RecordingBrokerTransport(responses: [ - .json(status: 200, body: body), - ]) - let client = try makeClient(transport: transport) - - let discovery = try await client.discover() - - #expect(discovery.relayFleet == relayFleet) - } - - @Test - func discoveryAcceptsDevelopmentBindingQuotaAboveProductionLimit() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json(status: 200, body: try Self.discoveryResponse(bindingCount: 33)), - ]) - let client = try makeClient(transport: transport) - - let discovery = try await client.discover() - - #expect(discovery.bindings.count == 33) - #expect(Set(discovery.bindings.map(\.bindingID)).count == 33) - } - - @Test - func discoveryRejectsBindingsAboveDevelopmentQuota() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json(status: 200, body: try Self.discoveryResponse(bindingCount: 257)), - ]) - let client = try makeClient(transport: transport) - - await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) { - _ = try await client.discover() - } - } - - @Test - func brokerErrorMapsOnlyStatusAndCoarseCode() async throws { - let transport = RecordingBrokerTransport(responses: [ - .json(status: 403, body: #"{"error":"target_not_pairable","secret":"do-not-copy"}"#), - ]) - let client = try makeClient(transport: transport) - await #expect(throws: CmxIrohTrustBrokerClientError.rejected( - statusCode: 403, - code: "target_not_pairable" - )) { - _ = try await client.issuePairGrant( - initiatorBindingID: "123e4567-e89b-42d3-a456-426614174001", - acceptorBindingID: "123e4567-e89b-42d3-a456-426614174002" - ) - } - } - - private func makeClient( - transport: RecordingBrokerTransport - ) throws -> CmxIrohTrustBrokerClient { - try CmxIrohTrustBrokerClient( - baseURL: #require(URL(string: "https://cmux.example")), - tokenSource: Self.tokenSource, - transport: transport - ) - } - - private func registrationSigner() throws -> CmxIrohRegistrationSigner { - let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init))) - let material = try CmxIrohIdentityMaterial( - secretKey: secret, - generation: 1 - ) - return try CmxIrohRegistrationSigner(identity: material, endpointID: Self.endpointID) - } - - private func registrationPayload() throws -> CmxIrohRegistrationPayload { - try CmxIrohRegistrationPayload( - deviceID: "123e4567-e89b-42d3-a456-426614174001", - appInstanceID: "123e4567-e89b-42d3-a456-426614174002", - tag: "stable", - platform: .ios, - endpointID: Self.endpointID, - identityGeneration: 1, - pairingEnabled: false, - capabilities: ["control"], - pathHints: [], - now: Date(timeIntervalSince1970: 1_782_000_000) - ) - } - - private static let tokenSource = CmxIrohBrokerTokenSource( - accessToken: { "access" }, - refreshToken: { "refresh" } - ) - private static let endpointID = - "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" - private static let bindingID = "123e4567-e89b-42d3-a456-426614174010" - private static let relayJWT = makeRelayJWT(endpointID: endpointID) - private static let relayURLs = [ - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/", - ] - - private static func base64URL(_ value: String) -> String { - Data(value.utf8).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - private static func makeRelayJWT(endpointID: String) -> String { - [ - base64URL(#"{"alg":"EdDSA","typ":"JWT"}"#), - base64URL( - #"{"iss":"cmux","aud":"cmux-relay","exp":1782000300,"endpoint_id":"\#(endpointID)"}"# - ), - "signature", - ].joined(separator: ".") - } - - private static func discoveryResponse(bindingCount: Int) throws -> String { - var object = try #require( - JSONSerialization.jsonObject( - with: Data(discoveryResponse.utf8) - ) as? [String: Any] - ) - let template = try #require((object["bindings"] as? [[String: Any]])?.first) - object["bindings"] = (1 ... bindingCount).map { index in - var binding = template - binding["binding_id"] = String( - format: "123e4567-e89b-42d3-a456-%012d", - index - ) - binding["app_instance_id"] = String( - format: "223e4567-e89b-42d3-a456-%012d", - index - ) - binding["endpoint_id"] = String(format: "%064llx", UInt64(index)) - return binding - } - let data = try JSONSerialization.data(withJSONObject: object) - return try #require(String(data: data, encoding: .utf8)) - } - - private static let registrationResponse = """ - { - "binding": { - "binding_id": "123e4567-e89b-42d3-a456-426614174010", - "device_id": "123e4567-e89b-42d3-a456-426614174001", - "app_instance_id": "123e4567-e89b-42d3-a456-426614174002", - "tag": "stable", - "platform": "ios", - "display_name": null, - "endpoint_id": "\(endpointID)", - "identity_generation": 1, - "pairing_enabled": false, - "capabilities": ["control"], - "path_hints": [], - "last_seen_at": "2026-07-10T00:00:00.000Z" - }, - "relay": { - "status": "issued", - "token": "abc234", - "expires_at": "2026-07-11T00:00:00.000Z", - "refresh_after": "2026-07-10T12:00:00.000Z", - "relay_fleet": [ - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/" - ] - } - } - """ - private static let discoveryResponse = """ - { - "route_contract_version": 1, - "bindings": [{ - "binding_id": "123e4567-e89b-42d3-a456-426614174010", - "device_id": "123e4567-e89b-42d3-a456-426614174001", - "app_instance_id": "123e4567-e89b-42d3-a456-426614174002", - "tag": "stable", - "platform": "mac", - "display_name": "Mac", - "endpoint_id": "\(endpointID)", - "identity_generation": 1, - "pairing_enabled": true, - "capabilities": ["control"], - "path_hints": [{ - "kind": "relay_url", - "value": "\(relayURLs[0])", - "source": "native", - "privacy_scope": "public_internet", - "observed_at": "2026-07-10T00:00:00.000Z", - "expires_at": "2026-07-10T01:00:00.000Z" - }], - "last_seen_at": "2026-07-10T00:00:00.000Z" - }], - "relay_fleet": ["\(relayURLs[0])"], - "lan_rendezvous": { - "generation": 1, - "key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - }, - "grant_verification_keys": { - "version": 1, - "current_kid": "current", - "keys": [] - } - } - """ -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/HostPolicyCacheTestFixture.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/HostPolicyCacheTestFixture.swift deleted file mode 100644 index 188a0faa..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/HostPolicyCacheTestFixture.swift +++ /dev/null @@ -1,175 +0,0 @@ -import CMUXMobileCore -import CryptoKit -import Foundation -@testable import CmuxIrohTransport - -struct HostPolicyCacheTestFixture { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let binding: CmxIrohBrokerBindingMetadata - let pairingEnabled: Bool - let capabilities: [String] - let keySet: CmxIrohGrantVerificationKeySet - let alternateKeySet: CmxIrohGrantVerificationKeySet - let lanRendezvous: CmxIrohLANRendezvous - - private let signingKey: Curve25519.Signing.PrivateKey - - init( - binding: CmxIrohBrokerBindingMetadata? = nil, - pairingEnabled: Bool = true, - capabilities: [String] = ["control", "multistream-v1"] - ) throws { - signingKey = Curve25519.Signing.PrivateKey() - let alternateKey = Curve25519.Signing.PrivateKey() - keySet = Self.keySet(id: "current", key: signingKey) - alternateKeySet = Self.keySet(id: "current", key: alternateKey) - self.binding = try binding ?? CmxIrohBrokerBindingMetadata( - bindingID: "123e4567-e89b-42d3-a456-426614174010", - deviceID: "123e4567-e89b-42d3-a456-426614174011", - appInstanceID: "123e4567-e89b-42d3-a456-426614174012", - tag: "cmux-ios-v0", - platform: .mac, - endpointID: CmxIrohPeerIdentity( - endpointID: String(repeating: "ab", count: 32) - ), - identityGeneration: 4 - ) - self.pairingEnabled = pairingEnabled - self.capabilities = capabilities - lanRendezvous = try JSONDecoder().decode( - CmxIrohLANRendezvous.self, - from: JSONSerialization.data(withJSONObject: [ - "generation": 3, - "key": Data(repeating: 9, count: 32).base64URL, - ]) - ) - } - - func expectation( - accountID: String = "account-a", - appInstanceID: String? = nil, - endpointID: CmxIrohPeerIdentity? = nil, - identityGeneration: Int? = nil, - pairingEnabled: Bool? = nil, - capabilities: [String]? = nil - ) throws -> CmxIrohHostPolicyExpectation { - try CmxIrohHostPolicyExpectation( - accountID: accountID, - deviceID: binding.deviceID, - appInstanceID: appInstanceID ?? binding.appInstanceID, - tag: binding.tag, - endpointID: endpointID ?? binding.endpointID, - identityGeneration: identityGeneration ?? binding.identityGeneration, - pairingEnabled: pairingEnabled ?? self.pairingEnabled, - capabilities: capabilities ?? self.capabilities - ) - } - - func policy( - keySet: CmxIrohGrantVerificationKeySet? = nil, - responseKeySet: CmxIrohGrantVerificationKeySet? = nil, - expiresAt: Date? = nil - ) throws -> CmxIrohCachedHostPolicy { - let selectedKeySet = keySet ?? self.keySet - let responseKeys = responseKeySet ?? selectedKeySet - return try CmxIrohCachedHostPolicy( - binding: binding, - pairingEnabled: pairingEnabled, - capabilities: capabilities, - grantVerificationKeys: selectedKeySet, - endpointAttestation: attestation( - expiresAt: expiresAt ?? now.addingTimeInterval(3_600), - responseKeySet: responseKeys - ), - lanRendezvous: lanRendezvous - ) - } - - func policySignedByOriginalKey( - publishedKeySet: CmxIrohGrantVerificationKeySet - ) throws -> CmxIrohCachedHostPolicy { - try policy(keySet: publishedKeySet, responseKeySet: publishedKeySet) - } - - private func attestation( - expiresAt: Date, - responseKeySet: CmxIrohGrantVerificationKeySet - ) throws -> CmxIrohEndpointAttestationResponse { - let issuedAt = Int64(now.timeIntervalSince1970) - 10 - let expiry = Int64(expiresAt.timeIntervalSince1970) - let claims: [String: Any] = [ - "version": 1, - "jti": "123e4567-e89b-42d3-a456-426614174099", - "sub": Data(repeating: 7, count: 32).base64URL, - "bindingId": binding.bindingID, - "deviceId": binding.deviceID, - "endpointId": binding.endpointID.endpointID, - "identityGeneration": binding.identityGeneration, - "platform": CmxIrohPlatform.mac.rawValue, - "iat": issuedAt, - "nbf": issuedAt, - "exp": expiry, - "alpn": "cmux/mobile/1", - "scope": "cmux.offline-pair.same-account", - ] - let header = try JSONSerialization.data( - withJSONObject: [ - "alg": "EdDSA", - "typ": "cmux-endpoint-attestation-v1+jwt", - "kid": "current", - ], - options: [.sortedKeys] - ).base64URL - let payload = try JSONSerialization.data( - withJSONObject: claims, - options: [.sortedKeys] - ).base64URL - let signingInput = "\(header).\(payload)" - let signature = try signingKey.signature( - for: Data(signingInput.utf8) - ).base64URL - return CmxIrohEndpointAttestationResponse( - attestationVersion: 1, - attestation: "\(signingInput).\(signature)", - expiresAt: Self.iso8601(Date(timeIntervalSince1970: TimeInterval(expiry))), - grantVerificationKeys: responseKeySet - ) - } - - private static func keySet( - id: String, - key: Curve25519.Signing.PrivateKey - ) -> CmxIrohGrantVerificationKeySet { - let prefix = Data([ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, - 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]) - return CmxIrohGrantVerificationKeySet( - version: 1, - currentKeyID: id, - keys: [ - CmxIrohGrantVerificationKey( - kid: id, - alg: "EdDSA", - spkiDerBase64: (prefix + key.publicKey.rawRepresentation) - .base64EncodedString() - ), - ] - ) - } - - private static func iso8601(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter.string(from: date) - } -} - -private extension Data { - var base64URL: String { - base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/HostRuntimeDeactivationRecorder.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/HostRuntimeDeactivationRecorder.swift deleted file mode 100644 index 5166d5de..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/HostRuntimeDeactivationRecorder.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -actor HostRuntimeDeactivationRecorder { - private var recorded: [String?] = [] - private var waiters: [ - (count: Int, continuation: CheckedContinuation<Void, Never>) - ] = [] - - func record(_ bindingID: String?) { - recorded.append(bindingID) - let ready = waiters.filter { recorded.count >= $0.count } - waiters.removeAll { recorded.count >= $0.count } - for waiter in ready { waiter.continuation.resume() } - } - - func waitForCount(_ count: Int) async { - if recorded.count >= count { return } - await withCheckedContinuation { continuation in - waiters.append((count, continuation)) - } - } - - func values() -> [String?] { recorded } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ImmediateHostActivationClock.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ImmediateHostActivationClock.swift deleted file mode 100644 index f87a1d6e..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/ImmediateHostActivationClock.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -/// Completes retry delays immediately so cold-start recovery tests stay deterministic. -struct ImmediateHostActivationClock: CmxIrohRelayClock { - private let date = Date(timeIntervalSince1970: 1_800_000_000) - - func now() -> Date { - date - } - - func sleep(until _: Date) async throws {} -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift deleted file mode 100644 index d38a5ba4..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/RelayPolicyServiceTestFixture.swift +++ /dev/null @@ -1,108 +0,0 @@ -import CryptoKit -import Foundation -@testable import CmuxIrohTransport - -struct RelayPolicyServiceTestFixture { - let firstPrivateKey = Curve25519.Signing.PrivateKey() - let secondPrivateKey = Curve25519.Signing.PrivateKey() - let now = Date(timeIntervalSince1970: 1_782_000_000) - let relayURLs = [ - "https://usc1.relay.cmux.dev/", - "https://euw4.relay.cmux.dev/", - ] - - var firstTrustRoot: CmxIrohRelayPolicyTrustRoot { - get throws { try trustRoot(includeFirst: true, includeSecond: false) } - } - - var rotatedTrustRoot: CmxIrohRelayPolicyTrustRoot { - get throws { try trustRoot(includeFirst: true, includeSecond: true) } - } - - var secondTrustRoot: CmxIrohRelayPolicyTrustRoot { - get throws { try trustRoot(includeFirst: false, includeSecond: true) } - } - - func token( - sequence: Int64, - signer: Int = 1, - expiresAt: Int64? = nil, - relayURLs: [String]? = nil - ) throws -> String { - let keyID = signer == 1 ? "policy-first" : "policy-second" - let privateKey = signer == 1 ? firstPrivateKey : secondPrivateKey - let header = try JSONSerialization.data( - withJSONObject: [ - "alg": "EdDSA", - "typ": "cmux-relay-policy-v1+jwt", - "kid": keyID, - ], - options: [.sortedKeys] - ) - let urls = relayURLs ?? self.relayURLs - let descriptors = urls.enumerated().map { index, url in - [ - "id": index == 0 ? "cmux-us" : "cmux-eu", - "provider": "cmux", - "region": index == 0 ? "us-central1" : "europe-west4", - "url": url, - ] - } - let nowSeconds = Int64(now.timeIntervalSince1970) - let payload = try JSONSerialization.data( - withJSONObject: [ - "version": 1, - "jti": "123e4567-e89b-42d3-a456-426614174000", - "sequence": sequence, - "iat": nowSeconds, - "nbf": nowSeconds, - "exp": expiresAt ?? nowSeconds + 3_600, - "aud": "cmux-iroh-relay-policy", - "relay_protocol": "iroh-relay-v1", - "relays": descriptors, - ], - options: [.sortedKeys] - ) - let input = "\(Self.base64URL(header)).\(Self.base64URL(payload))" - let signature = try privateKey.signature(for: Data(input.utf8)) - return "\(input).\(Self.base64URL(signature))" - } - - func relayCredential() -> CmxIrohRelayTokenResponse { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return CmxIrohRelayTokenResponse( - token: "aaaa", - expiresAt: formatter.string(from: now.addingTimeInterval(3_600)), - refreshAfter: formatter.string(from: now.addingTimeInterval(1_800)), - relayFleet: relayURLs - ) - } - - private func trustRoot( - includeFirst: Bool, - includeSecond: Bool - ) throws -> CmxIrohRelayPolicyTrustRoot { - var keys: [CmxIrohRelayPolicyVerificationKey] = [] - if includeFirst { - keys.append(try CmxIrohRelayPolicyVerificationKey( - keyID: "policy-first", - rawPublicKeyBase64: firstPrivateKey.publicKey.rawRepresentation.base64EncodedString() - )) - } - if includeSecond { - keys.append(try CmxIrohRelayPolicyVerificationKey( - keyID: "policy-second", - rawPublicKeyBase64: secondPrivateKey.publicKey.rawRepresentation.base64EncodedString() - )) - } - return try CmxIrohRelayPolicyTrustRoot(keys: keys) - } - - private static func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingIrohEndpointFactory.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingIrohEndpointFactory.swift deleted file mode 100644 index ea80927e..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingIrohEndpointFactory.swift +++ /dev/null @@ -1,33 +0,0 @@ -@testable import CmuxIrohTransport - -actor TestBlockingIrohEndpointFactory: CmxIrohEndpointFactory { - private let endpoint: TestIrohEndpoint - private let startedStream: AsyncStream<Void> - private let startedContinuation: AsyncStream<Void>.Continuation - private var pendingBind: CheckedContinuation<any CmxIrohEndpoint, any Error>? - - init(endpoint: TestIrohEndpoint) { - self.endpoint = endpoint - let started = AsyncStream<Void>.makeStream() - startedStream = started.stream - startedContinuation = started.continuation - } - - func bind( - configuration _: CmxIrohEndpointConfiguration - ) async throws -> any CmxIrohEndpoint { - startedContinuation.yield() - return try await withCheckedThrowingContinuation { continuation in - pendingBind = continuation - } - } - - func bindStartedEvents() -> AsyncStream<Void> { - startedStream - } - - func release() { - pendingBind?.resume(returning: endpoint) - pendingBind = nil - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingIrohReceiveStream.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingIrohReceiveStream.swift deleted file mode 100644 index ce2007a2..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingIrohReceiveStream.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -actor TestBlockingIrohReceiveStream: CmxIrohReceiveStream { - private var buffer: Data - private let cancellationUnblocksReceive: Bool - private var waiter: CheckedContinuation<Data?, any Error>? - private var cancelled = false - private var stoppedCodes: [UInt64] = [] - private let blockedStream: AsyncStream<Void> - private let blockedContinuation: AsyncStream<Void>.Continuation - private let stoppedStream: AsyncStream<UInt64> - private let stoppedContinuation: AsyncStream<UInt64>.Continuation - - init( - buffer: Data, - cancellationUnblocksReceive: Bool = true - ) { - self.buffer = buffer - self.cancellationUnblocksReceive = cancellationUnblocksReceive - let blocked = AsyncStream<Void>.makeStream() - blockedStream = blocked.stream - blockedContinuation = blocked.continuation - let stopped = AsyncStream<UInt64>.makeStream() - stoppedStream = stopped.stream - stoppedContinuation = stopped.continuation - } - - func receive(maximumByteCount: Int) async throws -> Data? { - guard maximumByteCount > 0 else { - throw CmxIrohClientSessionError.invalidMaximumByteCount(maximumByteCount) - } - if !buffer.isEmpty { - let count = min(maximumByteCount, buffer.count) - let value = Data(buffer.prefix(count)) - buffer.removeFirst(count) - return value - } - blockedContinuation.yield() - guard cancellationUnblocksReceive else { - return try await withCheckedThrowingContinuation { continuation in - waiter = continuation - } - } - try Task.checkCancellation() - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - if cancelled { - continuation.resume(throwing: CancellationError()) - } else { - waiter = continuation - } - } - } onCancel: { - Task { await self.cancelWaiter() } - } - } - - func stop(errorCode: UInt64) { - stoppedCodes.append(errorCode) - stoppedContinuation.yield(errorCode) - waiter?.resume(returning: nil) - waiter = nil - } - - func blockedEvents() -> AsyncStream<Void> { - blockedStream - } - - func observedStoppedCodes() -> [UInt64] { - stoppedCodes - } - - func stoppedEvents() -> AsyncStream<UInt64> { - stoppedStream - } - - private func cancelWaiter() { - cancelled = true - waiter?.resume(throwing: CancellationError()) - waiter = nil - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift deleted file mode 100644 index 69b72c0f..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestBlockingRelayUpdateEndpoint.swift +++ /dev/null @@ -1,67 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -actor TestBlockingRelayUpdateEndpoint: CmxIrohEndpoint { - private let peerIdentity: CmxIrohPeerIdentity - private let healthStream: AsyncStream<CmxIrohEndpointHealthEvent> - private let healthContinuation: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - private let updateStream: AsyncStream<Void> - private let updateContinuation: AsyncStream<Void>.Continuation - private var releaseContinuation: CheckedContinuation<Void, Never>? - - init(identity: CmxIrohPeerIdentity) { - peerIdentity = identity - let health = AsyncStream<CmxIrohEndpointHealthEvent>.makeStream() - healthStream = health.stream - healthContinuation = health.continuation - let updates = AsyncStream<Void>.makeStream() - updateStream = updates.stream - updateContinuation = updates.continuation - } - - func identity() -> CmxIrohPeerIdentity { - peerIdentity - } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: peerIdentity, pathHints: []) - } - - func connect( - to _: CmxIrohEndpointAddress, - alpn _: Data - ) async throws -> any CmxIrohConnection { - throw TestIrohTransportError.unsupported - } - - func accept() async throws -> (any CmxIrohConnection)? { - nil - } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) async { - updateContinuation.yield(()) - await withCheckedContinuation { continuation in - releaseContinuation = continuation - } - } - - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { - healthStream - } - - func isHealthy() -> Bool { true } - - func close() { - healthContinuation.finish() - } - - func updateEvents() -> AsyncStream<Void> { - updateStream - } - - func releaseUpdate() { - releaseContinuation?.resume() - releaseContinuation = nil - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestControllableSecureCredentialStore.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestControllableSecureCredentialStore.swift deleted file mode 100644 index a7d29066..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestControllableSecureCredentialStore.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -enum TestControllableSecureCredentialStoreError: Error, Equatable { - case writeFailed -} - -actor TestControllableSecureCredentialStore: CmxIrohSecureCredentialStoring { - private enum NextWrite { - case normal - case suspended - case failed - } - - private var records: [String: Data] = [:] - private var nextWrite = NextWrite.normal - private var suspendedWrite: CheckedContinuation<Void, Never>? - private var writeSuspensionWaiters: [CheckedContinuation<Void, Never>] = [] - private var shouldSuspendNextDeleteAll = false - private var suspendedDeleteAll: CheckedContinuation<Void, Never>? - private var deleteAllSuspensionWaiters: [CheckedContinuation<Void, Never>] = [] - private var storedDeleteAllCount = 0 - - func read(account: String) -> Data? { - records[account] - } - - func write( - _ data: Data, - account: String, - accessibility _: CmxIrohSecureCredentialAccessibility - ) async throws { - let behavior = nextWrite - nextWrite = .normal - switch behavior { - case .normal: - break - case .suspended: - await withCheckedContinuation { continuation in - suspendedWrite = continuation - let waiters = writeSuspensionWaiters - writeSuspensionWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - } - case .failed: - throw TestControllableSecureCredentialStoreError.writeFailed - } - records[account] = data - } - - func delete(account: String) { - records.removeValue(forKey: account) - } - - func deleteAll() async { - if shouldSuspendNextDeleteAll { - shouldSuspendNextDeleteAll = false - await withCheckedContinuation { continuation in - suspendedDeleteAll = continuation - let waiters = deleteAllSuspensionWaiters - deleteAllSuspensionWaiters.removeAll(keepingCapacity: false) - for waiter in waiters { waiter.resume() } - } - } - records.removeAll(keepingCapacity: false) - storedDeleteAllCount += 1 - } - - func suspendNextWrite() { - nextWrite = .suspended - } - - func failNextWrite() { - nextWrite = .failed - } - - func suspendNextDeleteAll() { - shouldSuspendNextDeleteAll = true - } - - func waitUntilWriteIsSuspended() async { - guard suspendedWrite == nil else { return } - await withCheckedContinuation { continuation in - writeSuspensionWaiters.append(continuation) - } - } - - func resumeSuspendedWrite() { - let continuation = suspendedWrite - suspendedWrite = nil - continuation?.resume() - } - - func waitUntilDeleteAllIsSuspended() async { - guard suspendedDeleteAll == nil else { return } - await withCheckedContinuation { continuation in - deleteAllSuspensionWaiters.append(continuation) - } - } - - func resumeSuspendedDeleteAll() { - let continuation = suspendedDeleteAll - suspendedDeleteAll = nil - continuation?.resume() - } - - func recordCount() -> Int { records.count } - func deleteAllCount() -> Int { storedDeleteAllCount } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift deleted file mode 100644 index 31e43a6d..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestDialingIrohEndpoint.swift +++ /dev/null @@ -1,66 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -actor TestDialingIrohEndpoint: CmxIrohEndpoint { - private let localIdentity: CmxIrohPeerIdentity - private var dialResults: [TestIrohDialResult] - private var dialedAddresses: [CmxIrohEndpointAddress] = [] - private let healthStream: AsyncStream<CmxIrohEndpointHealthEvent> - private let healthContinuation: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - - init( - localIdentity: CmxIrohPeerIdentity, - dialResults: [TestIrohDialResult] - ) { - self.localIdentity = localIdentity - self.dialResults = dialResults - let health = AsyncStream<CmxIrohEndpointHealthEvent>.makeStream() - healthStream = health.stream - healthContinuation = health.continuation - } - - func identity() -> CmxIrohPeerIdentity { - localIdentity - } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: localIdentity, pathHints: []) - } - - func connect( - to address: CmxIrohEndpointAddress, - alpn _: Data - ) throws -> any CmxIrohConnection { - dialedAddresses.append(address) - guard !dialResults.isEmpty else { - throw TestIrohTransportError.unsupported - } - switch dialResults.removeFirst() { - case let .connection(connection): - return connection - case let .failure(error): - throw error - } - } - - func accept() async throws -> (any CmxIrohConnection)? { - nil - } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { - healthStream - } - - func isHealthy() -> Bool { true } - - func close() { - healthContinuation.finish() - } - - func observedDialedAddresses() -> [CmxIrohEndpointAddress] { - dialedAddresses - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift deleted file mode 100644 index 1af9fb60..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestHangingDialEndpoint.swift +++ /dev/null @@ -1,74 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -actor TestHangingDialEndpoint: CmxIrohEndpoint { - private let localIdentity: CmxIrohPeerIdentity - private let startedStream: AsyncStream<Void> - private let startedContinuation: AsyncStream<Void>.Continuation - private let cancelledStream: AsyncStream<Void> - private let cancelledContinuation: AsyncStream<Void>.Continuation - private var pendingConnect: CheckedContinuation<any CmxIrohConnection, any Error>? - - init(localIdentity: CmxIrohPeerIdentity) { - self.localIdentity = localIdentity - let started = AsyncStream<Void>.makeStream() - startedStream = started.stream - startedContinuation = started.continuation - let cancelled = AsyncStream<Void>.makeStream() - cancelledStream = cancelled.stream - cancelledContinuation = cancelled.continuation - } - - func identity() -> CmxIrohPeerIdentity { - localIdentity - } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: localIdentity, pathHints: []) - } - - func connect( - to _: CmxIrohEndpointAddress, - alpn _: Data - ) async throws -> any CmxIrohConnection { - return try await withTaskCancellationHandler(operation: { - try await withCheckedThrowingContinuation { continuation in - pendingConnect = continuation - startedContinuation.yield() - } - }, onCancel: { [cancelledContinuation] in - cancelledContinuation.yield() - Task { await self.cancelPendingConnect() } - }) - } - - func accept() async throws -> (any CmxIrohConnection)? { - nil - } - - func replaceRelays(_: [CmxIrohRelayConfiguration]) {} - - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { - AsyncStream { $0.finish() } - } - - func isHealthy() -> Bool { true } - - func close() { - cancelPendingConnect() - } - - func startedEvents() -> AsyncStream<Void> { - startedStream - } - - func cancelledEvents() -> AsyncStream<Void> { - cancelledStream - } - - private func cancelPendingConnect() { - pendingConnect?.resume(throwing: CancellationError()) - pendingConnect = nil - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift deleted file mode 100644 index 743074d0..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientBroker.swift +++ /dev/null @@ -1,144 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -actor TestIrohClientBroker: CmxIrohClientBrokerServing { - private let registration: CmxIrohRegistrationResponse - private let discoveryResponse: CmxIrohDiscoveryResponse - private let relayResponse: CmxIrohRelayTokenResponse - private let revokeError: (any Error)? - private let registrationHook: (@Sendable (_ count: Int) async -> Void)? - private var registrationError: (any Error)? - private var registrationErrorsByCount: [Int: any Error] = [:] - private var preparedRegistrations: [CmxIrohPreparedRegistration] = [] - private var revokedBindingIDs: [String] = [] - private var relayIssueCount = 0 - private var registrationCountWaiters: [ - UUID: (minimum: Int, continuation: CheckedContinuation<Void, Never>) - ] = [:] - - init( - binding: CmxIrohBrokerBinding, - discovery: CmxIrohDiscoveryResponse, - relay: CmxIrohRelayTokenResponse, - issueRelayAtRegistration: Bool = true, - registrationError: (any Error)? = nil, - revokeError: (any Error)? = nil, - registrationHook: (@Sendable (_ count: Int) async -> Void)? = nil - ) { - registration = CmxIrohRegistrationResponse( - binding: binding, - relay: issueRelayAtRegistration ? .issued(relay) : .unavailable - ) - discoveryResponse = discovery - relayResponse = relay - self.revokeError = revokeError - self.registrationError = registrationError - self.registrationHook = registrationHook - } - - func register( - prepared: CmxIrohPreparedRegistration, - signer _: CmxIrohRegistrationSigner - ) async throws -> CmxIrohRegistrationResponse { - preparedRegistrations.append(prepared) - let count = preparedRegistrations.count - let readyIDs = registrationCountWaiters.compactMap { id, waiter in - count >= waiter.minimum ? id : nil - } - for id in readyIDs { - registrationCountWaiters.removeValue(forKey: id)?.continuation.resume() - } - await registrationHook?(count) - if let registrationError = registrationErrorsByCount[count] { - throw registrationError - } - if let registrationError { throw registrationError } - return registration - } - - func discover() -> CmxIrohDiscoveryResponse { - discoveryResponse - } - - func issuePairGrant( - initiatorBindingID _: String, - acceptorBindingID _: String - ) throws -> CmxIrohPairGrantResponse { - throw TestIrohTransportError.unsupported - } - - func issueRelayToken( - bindingID _: String, - endpointID _: CmxIrohPeerIdentity - ) -> CmxIrohRelayTokenResponse { - relayIssueCount += 1 - return relayResponse - } - - func revoke(bindingID: String) throws { - revokedBindingIDs.append(bindingID) - if let revokeError { throw revokeError } - } - - func observedRegistrations() -> [CmxIrohPreparedRegistration] { - preparedRegistrations - } - - func observedRevokedBindingIDs() -> [String] { - revokedBindingIDs - } - - func observedRelayIssueCount() -> Int { - relayIssueCount - } - - func setRegistrationError(_ error: (any Error)?) { - registrationError = error - } - - func setRegistrationError(_ error: any Error, forRegistrationCount count: Int) { - registrationErrorsByCount[count] = error - } - - func waitForRegistrationCount(_ minimum: Int) async { - if preparedRegistrations.count >= minimum { return } - let id = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume() - } else { - registrationCountWaiters[id] = (minimum, continuation) - } - } - } onCancel: { - Task { await self.cancelRegistrationWaiter(id) } - } - } - - func waitForRegistrationCount(_ minimum: Int, timeout: Duration) async -> Bool { - if preparedRegistrations.count >= minimum { return true } - return await withTaskGroup(of: Bool.self) { group in - group.addTask { - await self.waitForRegistrationCount(minimum) - return !Task.isCancelled - } - group.addTask { - do { - try await ContinuousClock().sleep(for: timeout) - } catch { - return false - } - return false - } - let result = await group.next() ?? false - group.cancelAll() - return result - } - } - - private func cancelRegistrationWaiter(_ id: UUID) { - registrationCountWaiters.removeValue(forKey: id)?.continuation.resume() - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientContextProvider.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientContextProvider.swift deleted file mode 100644 index 91a215c7..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohClientContextProvider.swift +++ /dev/null @@ -1,44 +0,0 @@ -import CMUXMobileCore -@testable import CmuxIrohTransport - -actor TestIrohClientContextProvider: CmxIrohClientContextProvider { - private let clientContext: CmxIrohClientContext - private let fallbackContext: CmxIrohClientContext? - private var observedRequests: [CmxByteTransportRequest] = [] - private var fallbackRequestCount = 0 - private var authorizations: [CmxIrohPrivateFallbackAuthorization] = [] - - init( - context: CmxIrohClientContext, - fallbackContext: CmxIrohClientContext? = nil - ) { - clientContext = context - self.fallbackContext = fallbackContext - } - - func context(for request: CmxByteTransportRequest) -> CmxIrohClientContext { - observedRequests.append(request) - return clientContext - } - - func requests() -> [CmxByteTransportRequest] { - observedRequests - } - - func contextWithPrivateFallback( - for _: CmxByteTransportRequest, - basedOn context: CmxIrohClientContext - ) -> CmxIrohClientContext { - fallbackRequestCount += 1 - return fallbackContext ?? context - } - - func validatePrivateFallback( - _ authorization: CmxIrohPrivateFallbackAuthorization - ) { - authorizations.append(authorization) - } - - func observedFallbackRequestCount() -> Int { fallbackRequestCount } - func observedAuthorizations() -> [CmxIrohPrivateFallbackAuthorization] { authorizations } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohConnection.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohConnection.swift deleted file mode 100644 index 062c2e60..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohConnection.swift +++ /dev/null @@ -1,196 +0,0 @@ -import CMUXMobileCore -@testable import CmuxIrohTransport - -actor TestIrohEventRecorder { - private var events: [String] = [] - - func record(_ event: String) { - events.append(event) - } - - func observedEvents() -> [String] { - events - } -} - -actor TestIrohConnection: CmxIrohConnection, CmxIrohConnectionPathInspecting { - private let peerIdentity: CmxIrohPeerIdentity - private var bidirectionalStreams: [CmxIrohBidirectionalStream] - private var receiveStreams: [any CmxIrohReceiveStream] - private let natTraversalAuthorizationError: TestIrohTransportError? - private let eventRecorder: TestIrohEventRecorder? - private let bidirectionalStreamFailureNumber: Int? - private let reportsClosureToWaiters: Bool - private var selectedPath: CmxIrohObservedConnectionPath - private let selectedPathStream: AsyncStream<CmxIrohObservedConnectionPath> - private let selectedPathContinuation: AsyncStream<CmxIrohObservedConnectionPath>.Continuation - private var incomingStreamLimits: [( - maximumBidirectionalStreamCount: UInt64, - maximumUnidirectionalStreamCount: UInt64 - )] = [] - private var bidirectionalStreamOpenCount = 0 - private var receiveStreamAcceptCount = 0 - private var natTraversalAuthorizationAttemptCount = 0 - private var natTraversalActivationCount = 0 - private var natTraversalAuthorized = false - private var closeCalls: [(code: UInt64, reason: String)] = [] - private var closeWaiters: [CheckedContinuation<Void, Never>] = [] - private let closeStream: AsyncStream<(code: UInt64, reason: String)> - private let closeContinuation: AsyncStream<(code: UInt64, reason: String)>.Continuation - - init( - remoteIdentity: CmxIrohPeerIdentity, - bidirectionalStreams: [CmxIrohBidirectionalStream], - receiveStreams: [any CmxIrohReceiveStream] = [], - natTraversalAuthorizationError: TestIrohTransportError? = nil, - eventRecorder: TestIrohEventRecorder? = nil, - selectedPath: CmxIrohObservedConnectionPath = .unavailable, - bidirectionalStreamFailureNumber: Int? = nil, - reportsClosureToWaiters: Bool = true - ) { - peerIdentity = remoteIdentity - self.bidirectionalStreams = bidirectionalStreams - self.receiveStreams = receiveStreams - self.natTraversalAuthorizationError = natTraversalAuthorizationError - self.eventRecorder = eventRecorder - self.bidirectionalStreamFailureNumber = bidirectionalStreamFailureNumber - self.reportsClosureToWaiters = reportsClosureToWaiters - self.selectedPath = selectedPath - let pathChanges = AsyncStream<CmxIrohObservedConnectionPath>.makeStream( - bufferingPolicy: .bufferingNewest(1) - ) - selectedPathStream = pathChanges.stream - selectedPathContinuation = pathChanges.continuation - selectedPathContinuation.yield(selectedPath) - let closes = AsyncStream<(code: UInt64, reason: String)>.makeStream() - closeStream = closes.stream - closeContinuation = closes.continuation - } - - func remoteIdentity() -> CmxIrohPeerIdentity { - peerIdentity - } - - func observedSelectedPath() -> CmxIrohObservedConnectionPath { - selectedPath - } - - func observedSelectedPathChanges() -> AsyncStream<CmxIrohObservedConnectionPath> { - selectedPathStream - } - - func setObservedSelectedPath(_ path: CmxIrohObservedConnectionPath) { - selectedPath = path - selectedPathContinuation.yield(path) - } - - func setIncomingStreamLimits( - maximumBidirectionalStreamCount: UInt64, - maximumUnidirectionalStreamCount: UInt64 - ) async { - incomingStreamLimits.append(( - maximumBidirectionalStreamCount, - maximumUnidirectionalStreamCount - )) - await eventRecorder?.record( - "connection.limits:\(maximumBidirectionalStreamCount):\(maximumUnidirectionalStreamCount)" - ) - } - - func openBidirectionalStream() async throws -> CmxIrohBidirectionalStream { - bidirectionalStreamOpenCount += 1 - if bidirectionalStreamOpenCount == bidirectionalStreamFailureNumber { - recordClose(errorCode: 99, reason: "timed_out") - throw TestIrohTransportError.unsupported - } - guard !bidirectionalStreams.isEmpty else { - throw TestIrohTransportError.unsupported - } - await eventRecorder?.record("connection.openBidirectionalStream") - return bidirectionalStreams.removeFirst() - } - - func acceptBidirectionalStream() async throws -> CmxIrohBidirectionalStream { - try await openBidirectionalStream() - } - - func openSendStream() throws -> any CmxIrohSendStream { - guard let sendStream = bidirectionalStreams.first?.sendStream else { - throw TestIrohTransportError.unsupported - } - return sendStream - } - - func acceptReceiveStream() throws -> any CmxIrohReceiveStream { - guard !receiveStreams.isEmpty else { - throw TestIrohTransportError.unsupported - } - receiveStreamAcceptCount += 1 - return receiveStreams.removeFirst() - } - - func close(errorCode: UInt64, reason: String) { - recordClose(errorCode: errorCode, reason: reason) - } - - func isClosed() -> Bool { - !closeCalls.isEmpty - } - - private func recordClose(errorCode: UInt64, reason: String) { - let firstClose = closeCalls.isEmpty - closeCalls.append((errorCode, reason)) - closeContinuation.yield((errorCode, reason)) - if firstClose, reportsClosureToWaiters { - let waiters = closeWaiters - closeWaiters.removeAll() - for waiter in waiters { waiter.resume() } - } - } - - func waitUntilClosed() async { - if reportsClosureToWaiters, !closeCalls.isEmpty { return } - await withCheckedContinuation { closeWaiters.append($0) } - } - - func authorizeNatTraversal() async throws { - natTraversalAuthorizationAttemptCount += 1 - await eventRecorder?.record("connection.authorizeNatTraversal") - if let natTraversalAuthorizationError { - throw natTraversalAuthorizationError - } - guard !natTraversalAuthorized else { return } - natTraversalAuthorized = true - natTraversalActivationCount += 1 - } - - func observedCloseCallCount() -> Int { - closeCalls.count - } - - func observedIncomingStreamLimits() -> [String] { - incomingStreamLimits.map { - "\($0.maximumBidirectionalStreamCount):\($0.maximumUnidirectionalStreamCount)" - } - } - - func observedBidirectionalStreamOpenCount() -> Int { - bidirectionalStreamOpenCount - } - - func observedReceiveStreamAcceptCount() -> Int { - receiveStreamAcceptCount - } - - func observedNatTraversalAuthorizationAttemptCount() -> Int { - natTraversalAuthorizationAttemptCount - } - - func observedNatTraversalActivationCount() -> Int { - natTraversalActivationCount - } - - func closeEvents() -> AsyncStream<(code: UInt64, reason: String)> { - closeStream - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohDialPlan.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohDialPlan.swift deleted file mode 100644 index 5fbaf8d7..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohDialPlan.swift +++ /dev/null @@ -1,38 +0,0 @@ -import CMUXMobileCore -import Foundation - -func testIrohDialPlan( - publicPaths requestedPublicPaths: [CmxIrohPathHint]? = nil, - privateFallbackPaths: [CmxIrohPathHint] = [] -) throws -> CmxIrohDialPlan { - let publicPaths = try requestedPublicPaths ?? [CmxIrohPathHint( - kind: .relayURL, - value: "https://relay.example.com/", - source: .native, - privacyScope: .publicInternet - )] - let identity = try CmxIrohPeerIdentity( - endpointID: String(repeating: "01", count: 32) - ) - let endpoint = CmxAttachEndpoint.peer( - identity: identity, - pathHints: publicPaths + privateFallbackPaths - ) - let now = privateFallbackPaths - .compactMap(\.observedAt) - .min()? - .addingTimeInterval(1) ?? Date() - let managedRelayURLs = Set(publicPaths.compactMap { hint in - hint.kind == .relayURL ? hint.value : nil - }) - let activeNetworkProfiles = Set(privateFallbackPaths.compactMap(\.networkProfile)) - - guard let dialPlan = endpoint.irohDialPlan( - at: now, - managedRelayURLs: managedRelayURLs, - activeNetworkProfiles: activeNetworkProfiles - ) else { - preconditionFailure("A peer endpoint must produce an Iroh dial plan") - } - return dialPlan -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohDialResult.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohDialResult.swift deleted file mode 100644 index 16665bc9..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohDialResult.swift +++ /dev/null @@ -1,4 +0,0 @@ -enum TestIrohDialResult { - case connection(TestIrohConnection) - case failure(TestIrohTransportError) -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift deleted file mode 100644 index 4ba37ba3..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpoint.swift +++ /dev/null @@ -1,107 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -actor TestIrohEndpoint: CmxIrohEndpoint { - private let peerIdentity: CmxIrohPeerIdentity - private let directAddresses: [String] - private var pathHints: [CmxIrohPathHint] - private let pathHintsAfterRelayReplacement: [CmxIrohPathHint]? - private let healthStream: AsyncStream<CmxIrohEndpointHealthEvent> - private let healthContinuation: AsyncStream<CmxIrohEndpointHealthEvent>.Continuation - private var closeCallCount = 0 - private var relayUpdates: [[CmxIrohRelayConfiguration]] = [] - private var relayProfileUpdates: [CmxIrohEndpointRelayProfile] = [] - private var relayUpdateShouldFail = false - private var healthy = true - - init( - identity: CmxIrohPeerIdentity, - directAddresses: [String] = [], - pathHints: [CmxIrohPathHint] = [], - pathHintsAfterRelayReplacement: [CmxIrohPathHint]? = nil - ) { - peerIdentity = identity - self.directAddresses = directAddresses - self.pathHints = pathHints - self.pathHintsAfterRelayReplacement = pathHintsAfterRelayReplacement - let health = AsyncStream<CmxIrohEndpointHealthEvent>.makeStream() - healthStream = health.stream - healthContinuation = health.continuation - } - - func identity() -> CmxIrohPeerIdentity { - peerIdentity - } - - func address() -> CmxIrohEndpointAddress { - CmxIrohEndpointAddress(identity: peerIdentity, pathHints: pathHints) - } - - func localDirectAddresses() -> [String] { directAddresses } - - func connect( - to _: CmxIrohEndpointAddress, - alpn _: Data - ) async throws -> any CmxIrohConnection { - throw TestIrohTransportError.unsupported - } - - func accept() async throws -> (any CmxIrohConnection)? { - nil - } - - func replaceRelays(_ relays: [CmxIrohRelayConfiguration]) throws { - if relayUpdateShouldFail { - throw TestIrohTransportError.relayUpdateFailed - } - relayUpdates.append(relays) - if let pathHintsAfterRelayReplacement { - pathHints = pathHintsAfterRelayReplacement - } - } - - func replaceRelayProfile(_ profile: CmxIrohEndpointRelayProfile) throws { - if relayUpdateShouldFail { - throw TestIrohTransportError.relayUpdateFailed - } - relayProfileUpdates.append(profile) - } - - func healthEvents() -> AsyncStream<CmxIrohEndpointHealthEvent> { - healthStream - } - - func isHealthy() -> Bool { - healthy - } - - func close() { - closeCallCount += 1 - healthContinuation.finish() - } - - func emit(_ event: CmxIrohEndpointHealthEvent) { - healthContinuation.yield(event) - } - - func setHealthy(_ value: Bool) { - healthy = value - } - - func setRelayUpdateShouldFail(_ shouldFail: Bool) { - relayUpdateShouldFail = shouldFail - } - - func observedCloseCallCount() -> Int { - closeCallCount - } - - func observedRelayUpdates() -> [[CmxIrohRelayConfiguration]] { - relayUpdates - } - - func observedRelayProfileUpdates() -> [CmxIrohEndpointRelayProfile] { - relayProfileUpdates - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpointFactory.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpointFactory.swift deleted file mode 100644 index c1061709..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohEndpointFactory.swift +++ /dev/null @@ -1,34 +0,0 @@ -@testable import CmuxIrohTransport - -actor TestIrohEndpointFactory: CmxIrohEndpointFactory { - private var endpoints: [any CmxIrohEndpoint] - private var configurations: [CmxIrohEndpointConfiguration] = [] - private let bindStream: AsyncStream<Int> - private let bindContinuation: AsyncStream<Int>.Continuation - - init(endpoints: [any CmxIrohEndpoint]) { - self.endpoints = endpoints - let binds = AsyncStream<Int>.makeStream() - bindStream = binds.stream - bindContinuation = binds.continuation - } - - func bind( - configuration: CmxIrohEndpointConfiguration - ) throws -> any CmxIrohEndpoint { - guard !endpoints.isEmpty else { - throw TestIrohTransportError.noEndpoint - } - configurations.append(configuration) - bindContinuation.yield(configurations.count) - return endpoints.removeFirst() - } - - func bindEvents() -> AsyncStream<Int> { - bindStream - } - - func observedConfigurations() -> [CmxIrohEndpointConfiguration] { - configurations - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohProtocolConfiguration.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohProtocolConfiguration.swift deleted file mode 100644 index b8ca2cab..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohProtocolConfiguration.swift +++ /dev/null @@ -1,27 +0,0 @@ -import CMUXMobileCore -import Foundation -@testable import CmuxIrohTransport - -extension CmxIrohProtocolConfiguration { - static let testApplicationLanes = CmxIrohProtocolConfiguration( - alpn: Data("cmux/mobile/1".utf8), - maximumHeaderByteCount: 16 * 1_024, - maximumConcurrentClientApplicationLaneCount: 16 - ) - - static let testRelayOnlyApplicationLanes = CmxIrohProtocolConfiguration( - alpn: Data("cmux/mobile/1".utf8), - maximumHeaderByteCount: 16 * 1_024, - maximumConcurrentClientApplicationLaneCount: 16, - allowsNATTraversalAfterAdmission: false - ) - - static let testDirectOnlyApplicationLanes = CmxIrohProtocolConfiguration( - alpn: Data("cmux/mobile/1".utf8), - maximumHeaderByteCount: 16 * 1_024, - maximumConcurrentClientApplicationLaneCount: 16, - allowsNATTraversalAfterAdmission: - CmxIrohTransportVerificationMode.directOnly - .allowsNATTraversalAfterAdmission - ) -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohReceiveStream.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohReceiveStream.swift deleted file mode 100644 index b2524c4e..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohReceiveStream.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -actor TestIrohReceiveStream: CmxIrohReceiveStream { - private var buffer: Data - private var stoppedCodes: [UInt64] = [] - - init(buffer: Data) { - self.buffer = buffer - } - - func receive(maximumByteCount: Int) throws -> Data? { - guard maximumByteCount > 0 else { - throw CmxIrohClientSessionError.invalidMaximumByteCount(maximumByteCount) - } - guard !buffer.isEmpty else { return nil } - let count = min(maximumByteCount, buffer.count) - let value = Data(buffer.prefix(count)) - buffer.removeFirst(count) - return value - } - - func stop(errorCode: UInt64) { - stoppedCodes.append(errorCode) - } - - func observedStoppedCodes() -> [UInt64] { - stoppedCodes - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohSendStream.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohSendStream.swift deleted file mode 100644 index 997e068f..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohSendStream.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -actor TestIrohSendStream: CmxIrohSendStream { - private let eventRecorder: TestIrohEventRecorder? - private let eventName: String? - private var sentBuffers: [Data] = [] - private var finishCallCount = 0 - private var resetCodes: [UInt64] = [] - private var priorities: [Int32] = [] - - init( - eventRecorder: TestIrohEventRecorder? = nil, - eventName: String? = nil - ) { - self.eventRecorder = eventRecorder - self.eventName = eventName - } - - func send(_ data: Data) async { - sentBuffers.append(data) - if let eventName { - await eventRecorder?.record(eventName) - } - } - - func finish() { - finishCallCount += 1 - } - - func reset(errorCode: UInt64) { - resetCodes.append(errorCode) - } - - func setPriority(_ priority: Int32) { - priorities.append(priority) - } - - func observedSentBuffers() -> [Data] { - sentBuffers - } - - func observedResetCodes() -> [UInt64] { - resetCodes - } - - func observedPriorities() -> [Int32] { - priorities - } -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohTransportError.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohTransportError.swift deleted file mode 100644 index afa70af1..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestIrohTransportError.swift +++ /dev/null @@ -1,6 +0,0 @@ -enum TestIrohTransportError: Error, Equatable { - case unsupported - case relayUpdateFailed - case noEndpoint - case natTraversalAuthorizationFailed -} diff --git a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestSecureCredentialStore.swift b/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestSecureCredentialStore.swift deleted file mode 100644 index 0bfd096a..00000000 --- a/vendor/CmuxIrohTransport/Tests/CmuxIrohTransportTests/TestSecureCredentialStore.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation -@testable import CmuxIrohTransport - -actor TestSecureCredentialStore: CmxIrohSecureCredentialStoring { - private var records: [String: Data] = [:] - private var accessibilities: [CmxIrohSecureCredentialAccessibility] = [] - private var storedDeleteAllCount = 0 - private var storedReadCount = 0 - private var lastAccount: String? - - func read(account: String) -> Data? { - storedReadCount += 1 - lastAccount = account - return records[account] - } - - func write( - _ data: Data, - account: String, - accessibility: CmxIrohSecureCredentialAccessibility - ) { - records[account] = data - accessibilities.append(accessibility) - lastAccount = account - } - - func delete(account: String) { - records.removeValue(forKey: account) - lastAccount = account - } - - func deleteAll() { - records.removeAll() - storedDeleteAllCount += 1 - } - - func seed(_ data: Data, account: String) { - records[account] = data - } - - func recordCount() -> Int { - records.count - } - - func observedAccessibilities() -> [CmxIrohSecureCredentialAccessibility] { - accessibilities - } - - func deleteAllCount() -> Int { - storedDeleteAllCount - } - - func readCount() -> Int { - storedReadCount - } - - func lastDeletedOrWrittenAccount() -> String? { - lastAccount - } - - func onlyStoredData() -> Data? { - guard records.count == 1 else { return nil } - return records.values.first - } -}