diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 9a08a824..aece7c56 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -153,12 +153,19 @@ jobs: xcrun --sdk macosx --show-sdk-path sw_vers + - name: Resolve Ghostty revision + id: ghostty-revision + run: | + set -euo pipefail + revision="$(./scripts/ghostty_cache_revision.sh)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Cache GhosttyKit.xcframework id: cache-ghosttykit uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: GhosttyKit.xcframework - key: ghosttykit-v2-${{ hashFiles('.gitmodules', 'ghostty') }} + key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - name: Set up Zig 0.16.0 run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6d81737..0db1156e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,29 +4,6 @@ on: push: branches: - main - paths: - - ".github/workflows/ci.yml" - - ".github/workflows/ci-macos-compat.yml" - - ".github/workflows/build-ghosttykit.yml" - - "Sources/**" - - "CLI/**" - - "CLI-MCP/**" - - "daemon/**" - - "programaTests/**" - - "tests/**" - - "tests_v2/**" - - "vendor/**" - - "scripts/**" - - "GhosttyTabs.xcodeproj/**" - # Both forms are required: "ghostty/**" matches file changes when the - # submodule content is diffed, but a submodule POINTER bump records the - # bare gitlink path "ghostty", which "ghostty/**" does not match -- - # without the exact entry, fork-bump commits skip CI (and therefore - # never auto-ship). - - "ghostty" - - "ghostty/**" - - "vendor/bonsplit/**" - - ".gitmodules" pull_request: paths: - "Sources/**" @@ -36,14 +13,22 @@ on: - "programaTests/**" - "tests/**" - "tests_v2/**" + - "Resources/**" + - "Assets.xcassets/**" - "vendor/**" - "scripts/**" - ".github/workflows/ci.yml" - ".github/workflows/ci-macos-compat.yml" - ".github/workflows/build-ghosttykit.yml" + - ".github/workflows/release.yml" - "GhosttyTabs.xcodeproj/**" - # Same as the push filter: the bare "ghostty" entry catches submodule - # pointer bumps, which "ghostty/**" alone misses. + - "programa.entitlements" + - "programa-Bridging-Header.h" + - "ghostty.h" + - "session_escrow_shim.c" + - "session_escrow_shim.h" + # The bare "ghostty" entry catches submodule pointer bumps, which + # "ghostty/**" alone misses. - "ghostty" - "ghostty/**" - ".gitmodules" @@ -106,51 +91,42 @@ jobs: HEAD_SHA="${{ github.event.pull_request.head.sha }}" git fetch --no-tags origin "${BASE_SHA}" "${HEAD_SHA}" CHANGED_FILES="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}")" - RUN_APP_JOBS=false - RUN_REMOTE_DAEMON_JOBS=false - if [[ -z "${CHANGED_FILES}" ]]; then - RUN_APP_JOBS=true - RUN_REMOTE_DAEMON_JOBS=true + 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 + echo "CI change classifier returned an invalid output shape" >&2 + exit 1 fi - while IFS= read -r path; do - [[ -z "$path" ]] && continue - - case "$path" in - # Documentation and prose - *.md|docs/*|plans/*|AGENTS.md|CHANGELOG.md|PROJECTS.md|TODO.md|README.md|LICENSE*|THIRD_PARTY_LICENSES.md|.editorconfig|.gitattributes|.gitignore|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.svg) - continue - ;; - # Repository metadata / workflow-only edits are not app/runtime changes - .github/*) - continue - ;; - # Localization-only resource edits are scoped out per request - Resources/*.xcstrings|Resources/*/*.xcstrings|Resources/*.strings|Resources/*/*.strings) - continue - ;; - # Explicitly skip doc-only translation assets - Resources/*.lproj/*) - continue - ;; - daemon/**) - RUN_REMOTE_DAEMON_JOBS=true - ;; - *) - RUN_APP_JOBS=true - break - ;; - esac - done <<< "$CHANGED_FILES" + case "$APP_OUTPUT" in + run_app_jobs=true) RUN_APP_JOBS=true ;; + run_app_jobs=false) RUN_APP_JOBS=false ;; + *) + echo "CI change classifier returned an invalid app result" >&2 + exit 1 + ;; + 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 - echo "run_app_jobs=${RUN_APP_JOBS}" >> "$GITHUB_OUTPUT" - echo "run_remote_daemon_jobs=${RUN_REMOTE_DAEMON_JOBS}" >> "$GITHUB_OUTPUT" + 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/localization-only; skipping heavy app/daemon jobs." + echo "Pull request appears docs/workflow/localization-only; skipping heavy app/daemon jobs." fi echo "" echo "Changed files:" @@ -181,6 +157,24 @@ jobs: - name: Validate release artifact architecture checks run: ./tests/test_ci_universal_release_settings.sh + - name: Validate rolling release publication + run: ./tests/test_rolling_release_publication.sh + + - name: Validate rolling release state transitions + run: node --test scripts/rolling_release_state.test.js + + - name: Validate release build identity + run: node --test scripts/release_build_identity.test.js + + - name: Validate milestone payload handoff + run: node --test scripts/milestone_payload.test.js + + - name: Validate milestone release publication + run: ./tests/test_milestone_release_publication.sh + + - name: Validate Sparkle monotonic release guard + run: node --test scripts/sparkle_monotonic_guard.test.js + - name: Validate release asset guard run: node scripts/release_asset_guard.test.js @@ -243,15 +237,28 @@ jobs: - name: Validate Release reload artifact discovery run: ./tests/test_reloadp_programa_artifact.sh + - name: Validate Ghostty cache revision + run: ./tests/test_ghostty_cache_revision.sh + + - name: Validate CI change classification + run: ./tests/test_ci_change_classification.sh + - name: Validate reload entrypoint artifacts and dependency preparation run: ./tests/test_reload_entrypoints_artifacts.sh + - name: Resolve Ghostty revision + id: ghostty-revision + run: | + set -euo pipefail + revision="$(./scripts/ghostty_cache_revision.sh)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Cache GhosttyKit.xcframework id: cache-ghosttykit uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: GhosttyKit.xcframework - key: ghosttykit-v2-${{ hashFiles('.gitmodules', 'ghostty') }} + key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - name: Download pre-built GhosttyKit.xcframework if: steps.cache-ghosttykit.outputs.cache-hit != 'true' @@ -453,12 +460,19 @@ jobs: sudo xcode-select -s "$XCODE_DIR" || true xcodebuild -version + - name: Resolve Ghostty revision + id: ghostty-revision + run: | + set -euo pipefail + revision="$(./scripts/ghostty_cache_revision.sh)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Cache GhosttyKit.xcframework id: cache-ghosttykit-tests-v2 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: GhosttyKit.xcframework - key: ghosttykit-v2-${{ hashFiles('.gitmodules', 'ghostty') }} + key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - name: Download pre-built GhosttyKit.xcframework if: steps.cache-ghosttykit-tests-v2.outputs.cache-hit != 'true' @@ -595,12 +609,19 @@ jobs: sudo xcode-select -s "$XCODE_DIR" || true xcodebuild -version + - name: Resolve Ghostty revision + id: ghostty-revision + run: | + set -euo pipefail + revision="$(./scripts/ghostty_cache_revision.sh)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Cache GhosttyKit.xcframework id: cache-ghosttykit-lag uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: GhosttyKit.xcframework - key: ghosttykit-v2-${{ hashFiles('.gitmodules', 'ghostty') }} + key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - name: Download pre-built GhosttyKit.xcframework if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 759fdf6a..a5986353 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: Release macOS app # Single-lane release pipeline: this is the ONLY macOS release lane. -# - workflow_run: auto-ships from `main` after the "CI" workflow succeeds (green CI on main), -# publishing to a single reused `rolling` release that is overwritten each ship. +# - workflow_run: every successful `main` CI run builds and seals a unique draft candidate; +# a serialized reconciler publishes it as a retained non-latest prerelease archive, then +# advances only the mutable aliases, metadata, and ref on `rolling`. # - push tags v*: milestone marketing-version releases (manual `scripts/bump-version.sh` + tag). # - workflow_dispatch: dry-run build that uploads an artifact instead of publishing. on: @@ -15,11 +16,8 @@ on: branches: [main] workflow_dispatch: -concurrency: - group: release-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: + actions: read contents: write attestations: write id-token: write @@ -27,13 +25,25 @@ permissions: env: CREATE_DMG_VERSION: 8.0.0 +concurrency: + group: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && 'programa-release-publication' || format('programa-release-{0}-{1}', github.run_id, github.run_attempt) }} + queue: max + cancel-in-progress: false + jobs: build-sign-notarize: # Only build for workflow_run events when the upstream CI run actually succeeded. # Tag pushes and manual dispatch always run. - if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.sha == github.event.workflow_run.head_sha) runs-on: macos-15 timeout-minutes: 60 + outputs: + effective_build: ${{ steps.version.outputs.effective_build }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -41,11 +51,66 @@ jobs: # For workflow_run, github.ref/github.sha resolve to the default branch's # workflow definition, not the commit that triggered CI. Pin to the exact # commit CI validated. - ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }} submodules: recursive + - name: Validate milestone tag identity + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + [[ "${GITHUB_REF_NAME}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + echo "Milestone tag must be canonical vMAJOR.MINOR.PATCH: ${GITHUB_REF_NAME}" >&2 + exit 1 + } + CHECKED_OUT_SHA="$(git rev-parse HEAD)" + [[ "${CHECKED_OUT_SHA}" == "${GITHUB_SHA}" ]] || { + echo "Milestone checkout ${CHECKED_OUT_SHA} does not match event SHA ${GITHUB_SHA}" >&2 + exit 1 + } + PROJECT_VERSION="$(grep -m1 'MARKETING_VERSION = ' GhosttyTabs.xcodeproj/project.pbxproj | sed 's/.*= //;s/;.*//')" + [[ "${GITHUB_REF_NAME}" == "v${PROJECT_VERSION}" ]] || { + echo "Milestone tag ${GITHUB_REF_NAME} does not match MARKETING_VERSION ${PROJECT_VERSION}" >&2 + exit 1 + } + + - name: Validate CI provenance against current main + if: github.event_name == 'workflow_run' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const upstream = context.payload.workflow_run; + if (upstream.event !== 'push' || upstream.head_branch !== 'main') { + core.setFailed('Rolling releases require a successful push CI run on main.'); + return; + } + if (!/^[0-9a-f]{40}$/.test(upstream.head_sha)) { + core.setFailed('The upstream CI head SHA is not a canonical commit SHA.'); + return; + } + if (context.sha !== upstream.head_sha) { + core.setFailed( + `Release provenance SHA ${context.sha} does not match upstream CI SHA ${upstream.head_sha}.` + ); + return; + } + const mainRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'heads/main', + }); + if (mainRef.data.object.sha !== upstream.head_sha) { + core.setFailed( + `Upstream CI SHA ${upstream.head_sha} is no longer current main ${mainRef.data.object.sha}.` + ); + } + - name: Determine release tag and effective build number id: version + env: + UPSTREAM_RUN_ID: ${{ github.event.workflow_run.id }} + UPSTREAM_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} run: | set -euo pipefail PROJECT_FILE="GhosttyTabs.xcodeproj/project.pbxproj" @@ -59,20 +124,32 @@ jobs: # run-ID builds would make the first milestone tag after any auto-ship fail the # monotonic guard below (and Sparkle would never offer it). Marketing version # (CFBundleShortVersionString, baked in at build time) carries the human semver. - RUN_ATTEMPT="$(printf '%02d' "${GITHUB_RUN_ATTEMPT:-1}")" - EFFECTIVE_BUILD="${GITHUB_RUN_ID}${RUN_ATTEMPT}" + EFFECTIVE_BUILD="$(node <<'NODE' + "use strict"; + const { deriveReleaseBuildIdentity } = require("./scripts/release_build_identity"); + process.stdout.write(deriveReleaseBuildIdentity({ + eventName: process.env.GITHUB_EVENT_NAME, + upstreamRunId: process.env.UPSTREAM_RUN_ID, + upstreamRunAttempt: process.env.UPSTREAM_RUN_ATTEMPT, + workflowRunId: process.env.WORKFLOW_RUN_ID, + workflowRunAttempt: process.env.WORKFLOW_RUN_ATTEMPT, + })); + NODE + )" - if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + if [[ "${GITHUB_EVENT_NAME:-}" == "push" && "${GITHUB_REF:-}" == refs/tags/* ]]; then # Milestone tag release: semver comes from the committed MARKETING_VERSION/tag. RELEASE_TAG="${GITHUB_REF_NAME}" + 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): publish to a - # single reused "rolling" release that is overwritten each ship, so the releases - # page stays clean (one rolling entry + permanent milestone v* tags) instead of - # accumulating one release per commit. + # 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 + # build-specific payload targets its retained non-latest candidate archive. RELEASE_TAG="rolling" + PAYLOAD_RELEASE_TAG="rolling-candidate-${EFFECTIVE_BUILD}" IS_AUTO_SHIP="true" # Every auto-ship gets a DISTINCT user-visible version: keep the committed # major.minor and replace the patch with the monotonic workflow run number. @@ -83,151 +160,86 @@ 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" echo "EFFECTIVE_MARKETING_VERSION=${EFFECTIVE_MARKETING_VERSION}" >> "$GITHUB_ENV" echo "EFFECTIVE_BUILD=${EFFECTIVE_BUILD}" >> "$GITHUB_ENV" 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})" echo "Effective build: ${EFFECTIVE_BUILD}" echo "Release tag: ${RELEASE_TAG}" echo "Auto-ship: ${IS_AUTO_SHIP}" - - name: Guard immutable release assets - id: guard_release_assets - # Only milestone v* tag releases are immutable — this guard prevents a re-run from - # clobbering already-signed milestone artifacts. Auto-ship publishes to the reused - # "rolling" release and intentionally overwrites, so it skips this guard entirely - # (when skipped, the empty skip_all/skip_upload outputs let the build/publish run). + - name: Restore durable milestone candidate + id: milestone_artifact if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - const { - enclosureAssetFromAppcast, - evaluateReleaseAssetGuard, - } = require('./scripts/release_asset_guard'); - const tag = process.env.RELEASE_TAG; - core.setOutput('skip_all', 'false'); - core.setOutput('skip_upload', 'false'); - core.setOutput('release_state', 'clear'); - try { - const release = await github.rest.repos.getReleaseByTag({ - owner: context.repo.owner, - repo: context.repo.repo, - tag, - }); - const existingAssets = release.data.assets || []; - const existingAssetNames = existingAssets.map((asset) => asset.name); - - // A published appcast names the exact DMG Sparkle will fetch. Read it so the - // guard requires that per-build asset too — otherwise a partial upload - // (appcast present, versioned DMG missing) looks "complete" on rerun and the - // release ships pointing at a 404. - // - // Fail closed: if an appcast exists but its enclosure can't be read, we can't - // tell a healthy release from a broken one. Skipping the build on that - // uncertainty is the exact failure this guard exists to prevent, so stop and - // make a human look instead. - let appcastXml = null; - const appcastAsset = existingAssets.find((asset) => asset.name === 'appcast.xml'); - if (appcastAsset) { - let readError = null; - try { - // Must be the typed endpoint: `github.request('GET {url}', ...)` would - // percent-encode the asset url as an RFC 6570 simple expansion and 404. - const response = await github.rest.repos.getReleaseAsset({ - owner: context.repo.owner, - repo: context.repo.repo, - asset_id: appcastAsset.id, - headers: { accept: 'application/octet-stream' }, - }); - appcastXml = Buffer.from(response.data).toString('utf8'); - } catch (error) { - readError = error.message; - } - - if (readError !== null) { - core.setFailed( - `Release ${tag} has a published appcast.xml that could not be read (${readError}). ` + - 'Cannot verify its Sparkle enclosure is uploaded; refusing to skip the build. ' + - 'Re-run once the asset is readable, or resolve release assets manually.' - ); - return; - } - - if (!enclosureAssetFromAppcast(appcastXml)) { - core.setFailed( - `Release ${tag} has an appcast.xml with no per-build Sparkle enclosure ` + - '(expected programa-macos-.dmg). It predates the immutable-enclosure fix ' + - 'or is malformed. Resolve release assets manually before rerunning.' - ); - return; - } - } - - const { - conflicts, - missingImmutableAssets, - guardState, - hasPartialConflict, - shouldSkipBuildAndUpload, - } = evaluateReleaseAssetGuard({ existingAssetNames, appcastXml }); - - core.setOutput('release_state', guardState); - - if (hasPartialConflict) { - core.setFailed( - `Release ${tag} has a partial immutable asset state. Existing immutable assets: ` + - `${conflicts.join(', ')}. Missing immutable assets: ${missingImmutableAssets.join(', ')}. ` + - 'Resolve release assets manually before rerunning.' - ); - return; - } - - if (shouldSkipBuildAndUpload) { - core.notice( - `Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` + - 'Skipping build, notarization, and upload to preserve existing signed artifacts.' - ); - core.setOutput('skip_all', 'true'); - core.setOutput('skip_upload', 'true'); - return; - } - - core.notice(`Release ${tag} exists but has no immutable release assets yet; continuing.`); - } catch (error) { - if (error.status === 404) { - core.notice(`Release ${tag} does not exist yet; safe to build and publish assets.`); - return; - } - throw error; - } - - - name: Guard Sparkle build number is monotonic - if: steps.guard_release_assets.outputs.skip_all != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - echo "Effective CURRENT_PROJECT_VERSION=$EFFECTIVE_BUILD" - PUBLISHED_BUILD=$(curl -fsSL --max-time 15 \ - https://github.com/darkroomengineering/programa/releases/latest/download/appcast.xml 2>/dev/null \ - | sed -n 's#.*\([0-9][0-9]*\).*#\1#p' \ - | head -n1 || true) - if [[ "$PUBLISHED_BUILD" =~ ^[0-9]+$ ]]; then - echo "Latest published Sparkle build=$PUBLISHED_BUILD" - if (( EFFECTIVE_BUILD <= PUBLISHED_BUILD )); then - echo "::error::Effective build number ($EFFECTIVE_BUILD) must be > latest published Sparkle build ($PUBLISHED_BUILD). Build numbers derive from the monotonic GitHub run ID, so this is unexpected — likely a re-run of an older run or a stale published appcast." >&2 - exit 1 - fi + MILESTONE_PAYLOAD_DIR="${RUNNER_TEMP}/programa-milestone-payload" + mkdir "$MILESTONE_PAYLOAD_DIR" + set +e + ./scripts/restore_release_candidate.sh \ + --candidate-prefix milestone-candidate- \ + --destination-tag "$RELEASE_TAG" \ + --target-sha "$GITHUB_SHA" \ + --build "$EFFECTIVE_BUILD" \ + --version "$EFFECTIVE_MARKETING_VERSION" \ + --output-dir "$MILESTONE_PAYLOAD_DIR" + restore_status=$? + set -e + if [[ "$restore_status" -eq 0 ]]; then + echo "reuse=true" >> "$GITHUB_OUTPUT" + echo "MILESTONE_PAYLOAD_DIR=${MILESTONE_PAYLOAD_DIR}" >> "$GITHUB_ENV" + elif [[ "$restore_status" -eq 3 ]]; then + rmdir "$MILESTONE_PAYLOAD_DIR" + echo "reuse=false" >> "$GITHUB_OUTPUT" else - echo "Latest published appcast unavailable; skipping monotonic check" + exit "$restore_status" fi + - name: Require current main before milestone build + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') && + steps.milestone_artifact.outputs.reuse != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + MAIN_SHA="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" --jq .object.sha)" + [[ "$GITHUB_SHA" == "$MAIN_SHA" ]] || { + echo "Milestone tag commit ${GITHUB_SHA} is not current main ${MAIN_SHA}" >&2 + exit 1 + } + + - name: Guard Sparkle build number is monotonic + if: github.event_name == 'workflow_dispatch' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + EFFECTIVE_BUILD: ${{ env.EFFECTIVE_BUILD }} + with: + script: | + const { enforceSparkleMonotonicBuild } = require('./scripts/sparkle_monotonic_guard'); + await enforceSparkleMonotonicBuild({ + github, + owner: context.repo.owner, + repo: context.repo.repo, + effectiveBuild: process.env.EFFECTIVE_BUILD, + }); + core.notice(`Sparkle build ${process.env.EFFECTIVE_BUILD} is newer than the latest release.`); + - name: Select Xcode - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | set -euo pipefail # Prefer Xcode 26 (macOS 26 SDK) so NSGlassEffectView / Liquid Glass compiles; @@ -250,8 +262,14 @@ jobs: xcodebuild -version xcrun --sdk macosx --show-sdk-path + - name: Setup Bun + if: steps.milestone_artifact.outputs.reuse != 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + - name: Install build deps - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | ZIG_REQUIRED="0.16.0" if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then @@ -268,21 +286,31 @@ jobs: fi ./scripts/install-create-dmg.sh + - name: Resolve Ghostty revision + id: ghostty-revision + if: steps.milestone_artifact.outputs.reuse != 'true' + run: | + set -euo pipefail + revision="$(./scripts/ghostty_cache_revision.sh)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + - name: Cache GhosttyKit.xcframework id: cache-ghosttykit-release - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: GhosttyKit.xcframework - key: ghosttykit-v2-${{ hashFiles('.gitmodules', 'ghostty') }} + key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} - name: Download pre-built GhosttyKit.xcframework - if: steps.guard_release_assets.outputs.skip_all != 'true' && steps.cache-ghosttykit-release.outputs.cache-hit != 'true' + if: >- + steps.milestone_artifact.outputs.reuse != 'true' && + steps.cache-ghosttykit-release.outputs.cache-hit != 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .spm-cache @@ -290,13 +318,13 @@ jobs: restore-keys: spm- - name: Setup Go - if: steps.guard_release_assets.outputs.skip_all != 'true' + 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.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -309,7 +337,7 @@ jobs: echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - name: Build universal app (Release) - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | xcodebuild -scheme programa -configuration Release -derivedDataPath build-universal \ -destination 'generic/platform=macOS' \ @@ -319,7 +347,7 @@ jobs: CODE_SIGNING_ALLOWED=NO build - name: Verify binary architectures - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | ./scripts/verify-release-architectures.sh \ "build-universal/Build/Products/Release/Programa.app/Contents/MacOS/Programa" \ @@ -327,7 +355,7 @@ jobs: "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" - name: Archive dSYMs - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | set -euo pipefail # dwarf-with-dsym (Release config) writes each target's .dSYM next to its @@ -346,7 +374,7 @@ jobs: zip -r -y "$DSYM_ZIP" "${DSYM_PATHS[@]}" - name: Stamp effective version and build number in Info.plist - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | set -euo pipefail APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" @@ -359,22 +387,24 @@ jobs: /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${EFFECTIVE_MARKETING_VERSION}" "$APP_PLIST" - name: Build remote daemon release assets and inject manifest - if: steps.guard_release_assets.outputs.skip_all != 'true' + 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 "$RELEASE_TAG" \ + --release-tag "$PAYLOAD_RELEASE_TAG" \ --repo "darkroomengineering/programa" \ - --output-dir "remote-daemon-assets" - MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/programad-remote-manifest.json)" + --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.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | set -euo pipefail CLI_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" @@ -382,14 +412,14 @@ jobs: PROGRAMA_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py - name: Verify bundled Ghostty theme picker helper - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | set -euo pipefail HELPER_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" [ -x "$HELPER_BINARY" ] || { echo "Ghostty theme picker helper not found at $HELPER_BINARY" >&2; exit 1; } - name: Inject Sparkle keys into Info.plist - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true @@ -403,7 +433,7 @@ jobs: /usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST" - name: Import signing cert - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -427,7 +457,7 @@ jobs: security list-keychains -d user -s build.keychain - name: Import provisioning profile - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' env: APPLE_PROVISION_PROFILE_BASE64: ${{ secrets.APPLE_PROVISION_PROFILE_BASE64 }} run: | @@ -449,7 +479,7 @@ jobs: echo "PROGRAMA_PROVISION_PROFILE=/tmp/programa.provisionprofile" >> "$GITHUB_ENV" - name: Codesign app - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -466,27 +496,26 @@ jobs: ./scripts/verify-provision-profile.sh "$APP_PATH" - name: Verify embedded Sparkle artifact - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' run: | ./scripts/verify_sparkle_artifact.sh \ "build-universal/Build/Products/Release/Programa.app" \ "2.9.4" - name: Notarize app - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | + set -euo pipefail if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 exit 1 fi APP_PATH="build-universal/Build/Products/Release/Programa.app" ZIP_SUBMIT="programa-notary.zip" - DMG_RELEASE="programa-macos.dmg" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT" APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")" @@ -500,15 +529,42 @@ jobs: xcrun stapler validate "$APP_PATH" spctl -a -vv --type execute "$APP_PATH" rm -f "$ZIP_SUBMIT" + + - name: Create signed DMG + if: steps.milestone_artifact.outputs.reuse != 'true' + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + set -euo pipefail + [[ -n "${APPLE_SIGNING_IDENTITY}" ]] || { + echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 + exit 1 + } + APP_PATH="build-universal/Build/Products/Release/Programa.app" + DMG_RELEASE="programa-macos.dmg" # create-dmg generates a styled drag-to-install DMG create-dmg \ --identity="$APPLE_SIGNING_IDENTITY" \ "$APP_PATH" \ ./ - # create-dmg (npm) names the DMG after the app bundle, e.g. "Programa 0.1.0.dmg". + # create-dmg names the DMG after the app bundle, e.g. "Programa 0.1.0.dmg". # GitHub macOS runners use a case-sensitive filesystem, so the glob must match # the capitalized product name exactly. mv ./Programa*.dmg "$DMG_RELEASE" + + - name: Notarize DMG + if: steps.milestone_artifact.outputs.reuse != 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then + echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 + exit 1 + fi + DMG_RELEASE="programa-macos.dmg" DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" @@ -519,17 +575,23 @@ jobs: fi xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + + - name: Create build-specific DMG alias + if: steps.milestone_artifact.outputs.reuse != 'true' + run: | + set -euo pipefail + DMG_RELEASE="programa-macos.dmg" # Publish the same bytes twice: programa-macos.dmg is the stable README # download button, and programa-macos-.dmg is what Sparkle's appcast - # points at. The versioned name is never overwritten by a later ship, so the - # EdDSA signature always matches whatever that url returns. See + # points at. The publisher retains each build archive and never overwrites its + # payload assets, so the EdDSA signature always matches that URL. See # scripts/sparkle_enclosure.js for why (SUSparkleErrorDomain 4005, 2026-07-24). ENCLOSURE_DMG="$(node scripts/sparkle_enclosure.js name "$EFFECTIVE_BUILD")" cp "$DMG_RELEASE" "$ENCLOSURE_DMG" echo "ENCLOSURE_DMG=${ENCLOSURE_DMG}" >> "$GITHUB_ENV" - name: Generate Sparkle appcast - if: steps.guard_release_assets.outputs.skip_all != 'true' + if: steps.milestone_artifact.outputs.reuse != 'true' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -537,22 +599,140 @@ jobs: echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 exit 1 fi - ./scripts/sparkle_generate_appcast.sh programa-macos.dmg "$RELEASE_TAG" appcast.xml "$ENCLOSURE_DMG" + ./scripts/sparkle_generate_appcast.sh programa-macos.dmg "$PAYLOAD_RELEASE_TAG" appcast.xml "$ENCLOSURE_DMG" - - name: Attest remote daemon release assets - if: steps.guard_release_assets.outputs.skip_all != 'true' + - name: Create milestone payload manifest + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') && + steps.milestone_artifact.outputs.reuse != 'true' + run: | + set -euo pipefail + MILESTONE_PAYLOAD_DIR="${RUNNER_TEMP}/programa-milestone-payload" + mkdir "$MILESTONE_PAYLOAD_DIR" + cp appcast.xml "$MILESTONE_PAYLOAD_DIR/appcast.xml" + 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"); + const { + validateMilestonePayloadReferences, + writeMilestoneManifest, + } = require(path.resolve("scripts/milestone_payload.js")); + writeMilestoneManifest({ directory: process.argv[2], build: process.argv[3] }); + validateMilestonePayloadReferences({ + directory: process.argv[2], + build: process.argv[3], + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.RELEASE_TAG, + version: process.env.EFFECTIVE_MARKETING_VERSION, + }); + NODE + echo "MILESTONE_PAYLOAD_DIR=${MILESTONE_PAYLOAD_DIR}" >> "$GITHUB_ENV" + + - name: Attest release candidate payloads + if: steps.milestone_artifact.outputs.reuse != 'true' uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | - remote-daemon-assets/programad-remote-darwin-arm64 - remote-daemon-assets/programad-remote-darwin-amd64 - remote-daemon-assets/programad-remote-linux-arm64 - remote-daemon-assets/programad-remote-linux-amd64 - remote-daemon-assets/programad-remote-checksums.txt - remote-daemon-assets/programad-remote-manifest.json + 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 + + - name: Prepare milestone release candidate seal + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') && + steps.milestone_artifact.outputs.reuse != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + ((GITHUB_RUN_ATTEMPT >= 1 && GITHUB_RUN_ATTEMPT <= 999)) || { + echo "Workflow attempt must be between 1 and 999" >&2 + exit 1 + } + printf -v CANDIDATE_ATTEMPT '%03d' "$GITHUB_RUN_ATTEMPT" + CANDIDATE_TAG="milestone-candidate-${EFFECTIVE_BUILD}-${CANDIDATE_ATTEMPT}" + CANDIDATE_SEAL="${RUNNER_TEMP}/programa-release-candidate.json" + ./scripts/publish_release_candidate.sh \ + --prepare-only \ + --candidate-prefix milestone-candidate- \ + --destination-tag "$RELEASE_TAG" \ + --candidate-tag "$CANDIDATE_TAG" \ + --target-sha "$GITHUB_SHA" \ + --build "$EFFECTIVE_BUILD" \ + --version "$EFFECTIVE_MARKETING_VERSION" \ + --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" + + - name: Attest milestone release candidate seal + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') && + steps.milestone_artifact.outputs.reuse != 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: ${{ env.CANDIDATE_SEAL }} + + - name: Stage milestone release candidate with attested seal + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') && + steps.milestone_artifact.outputs.reuse != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + printf -v CANDIDATE_ATTEMPT '%03d' "$GITHUB_RUN_ATTEMPT" + ./scripts/publish_release_candidate.sh \ + --candidate-prefix milestone-candidate- \ + --destination-tag "$RELEASE_TAG" \ + --candidate-tag "milestone-candidate-${EFFECTIVE_BUILD}-${CANDIDATE_ATTEMPT}" \ + --target-sha "$GITHUB_SHA" \ + --build "$EFFECTIVE_BUILD" \ + --version "$EFFECTIVE_MARKETING_VERSION" \ + --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" - name: Upload build artifacts (dry-run) - if: steps.guard_release_assets.outputs.skip_upload != 'true' && github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: programa-release-dry-run @@ -564,105 +744,108 @@ jobs: remote-daemon-assets/programad-remote-* if-no-files-found: error - - name: Generate rolling release notes (since previous ship) - id: rolling_notes - if: github.event_name == 'workflow_run' && steps.guard_release_assets.outputs.skip_all != 'true' + - name: Upload milestone release assets + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: programa-milestone-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ env.MILESTONE_PAYLOAD_DIR }}/ + if-no-files-found: error + + - name: Prepare rolling release candidate seal + if: github.event_name == 'workflow_run' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - # The rolling release is reused each ship; generate_release_notes on an - # existing release accumulates every past ship's "What's Changed" into - # one ever-growing body. Instead, generate notes for just the span since - # the previous ship (old rolling tag -> new commit) and REPLACE the body. - # Must run BEFORE the rolling tag is force-moved below. - NEW_SHA="${{ github.event.workflow_run.head_sha }}" - ARGS=(-f tag_name="rolling-next" -f target_commitish="$NEW_SHA") - PREV_SHA="" - if PREV_SHA=$(gh api "repos/${{ github.repository }}/git/ref/tags/rolling" --jq .object.sha 2>/dev/null); then - ARGS+=(-f previous_tag_name="rolling") + CURRENT_MAIN="$(gh api \ + "repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ + --jq .object.sha)" + EXPECTED_TARGET="${{ github.event.workflow_run.head_sha }}" + CHECKED_OUT_TARGET="$(git rev-parse HEAD)" + [[ "${CHECKED_OUT_TARGET}" == "${EXPECTED_TARGET}" ]] || { + echo "Checked-out revision ${CHECKED_OUT_TARGET} does not match upstream CI SHA ${EXPECTED_TARGET}" >&2 + exit 1 + } + [[ "${CURRENT_MAIN}" =~ ^[0-9a-f]{40}$ ]] || { + echo "Current main did not resolve to a canonical commit SHA" >&2 + exit 1 + } + [[ "${CURRENT_MAIN}" == "${EXPECTED_TARGET}" ]] || { + echo "Upstream CI SHA ${EXPECTED_TARGET} is no longer current main ${CURRENT_MAIN}" >&2 + exit 1 + } + DSYM_ZIPS=() + for dsym_zip in programa-dSYMs-*.zip; do + if [[ -f "$dsym_zip" ]]; then + DSYM_ZIPS+=("$dsym_zip") + fi + done + if [[ "${#DSYM_ZIPS[@]}" -ne 1 ]]; then + echo "Expected exactly one generated dSYM archive, found ${#DSYM_ZIPS[@]}" >&2 + exit 1 fi - BODY=$(gh api "repos/${{ github.repository }}/releases/generate-notes" "${ARGS[@]}" --jq .body) - # The synthetic rolling-next tag never exists; fix the compare link to real SHAs. - if [ -n "$PREV_SHA" ]; then - BODY=$(printf '%s' "$BODY" | sed "s|/compare/rolling...rolling-next|/compare/${PREV_SHA}...${NEW_SHA}|") + DSYM_ZIP="${DSYM_ZIPS[0]}" + if [[ "$DSYM_ZIP" != "programa-dSYMs-${EFFECTIVE_BUILD}.zip" ]]; then + echo "Unexpected dSYM archive for build ${EFFECTIVE_BUILD}: $DSYM_ZIP" >&2 + exit 1 fi - { - echo "body<> "$GITHUB_OUTPUT" - - name: Move rolling tag to shipped commit - if: github.event_name == 'workflow_run' && steps.guard_release_assets.outputs.skip_all != 'true' - run: | - set -euo pipefail - # Reuse a single "rolling" release: point its tag at the commit we just shipped so - # the release page reflects the actual build. Assets are overwritten below. - git tag -f rolling "${{ github.event.workflow_run.head_sha }}" - git push origin refs/tags/rolling --force - - - name: Upload release asset - if: steps.guard_release_assets.outputs.skip_upload != 'true' && (github.event_name == 'workflow_run' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))) - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + ./scripts/publish_release_candidate.sh \ + --prepare-only \ + --candidate-prefix rolling-candidate- \ + --destination-tag "$PAYLOAD_RELEASE_TAG" \ + --candidate-tag "$PAYLOAD_RELEASE_TAG" \ + --target-sha "${{ github.event.workflow_run.head_sha }}" \ + --seal-output programa-release-candidate.json \ + --build "$EFFECTIVE_BUILD" \ + --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" + + - name: Attest rolling release candidate seal + if: github.event_name == 'workflow_run' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: - tag_name: ${{ env.RELEASE_TAG }} - name: ${{ env.IS_AUTO_SHIP == 'true' && format('Rolling {0} (latest main)', env.EFFECTIVE_MARKETING_VERSION) || env.RELEASE_TAG }} - make_latest: true - # Sequential upload, and this order specifically: - # 1. programa-macos-.dmg — up before anything references it, so the new - # appcast's enclosure is never briefly a 404. - # 2. appcast.xml — now points at an asset that already exists. - # 3. programa-macos.dmg — overwritten LAST. Until this moment a client holding - # the PREVIOUS appcast (which still names the unversioned dmg) is served the - # previous bytes, matching the signature it has. Overwriting it first would - # recreate the 4005 mismatch during the upload window. - preserve_order: true - files: | - programa-macos-*.dmg - programa-dSYMs-*.zip - appcast.xml - programa-macos.dmg - remote-daemon-assets/programad-remote-darwin-arm64 - remote-daemon-assets/programad-remote-darwin-amd64 - remote-daemon-assets/programad-remote-linux-arm64 - remote-daemon-assets/programad-remote-linux-amd64 - remote-daemon-assets/programad-remote-checksums.txt - remote-daemon-assets/programad-remote-manifest.json - # Auto-ship: replace the body with freshly-scoped notes (previous ship -> now) - # so the reused rolling release doesn't accumulate every past "What's Changed". - # Milestone v* tags: a fresh release, so GitHub-generated notes are fine. - body: ${{ steps.rolling_notes.outputs.body }} - generate_release_notes: ${{ env.IS_AUTO_SHIP != 'true' }} - overwrite_files: true - - - name: Prune superseded Sparkle enclosures - if: steps.guard_release_assets.outputs.skip_upload != 'true' && github.event_name == 'workflow_run' + subject-path: programa-release-candidate.json + + - name: Stage rolling release candidate with attested seal + if: github.event_name == 'workflow_run' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Housekeeping only: the ship already succeeded by this point, so a failure here - # must not fail the run. Worst case a stale DMG lingers, costing storage. - continue-on-error: true - run: | - set -uo pipefail - # Versioned DMGs are never overwritten (that is the whole point), so they - # accumulate ~21MB per ship on the reused rolling release. The keep window is - # deliberately generous — see DEFAULT_KEEP_BUILDS in scripts/sparkle_enclosure.js - # for why deleting one is a real (if mild) auto-update failure. - if ! ASSETS=$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name'); then - echo "Could not list release assets; skipping prune." - exit 0 - fi - STALE=$(node scripts/sparkle_enclosure.js prune --current "$EFFECTIVE_BUILD" <<< "$ASSETS") - if [ -z "$STALE" ]; then - echo "No superseded enclosures to prune." - exit 0 - fi - while IFS= read -r asset; do - echo "Pruning $asset" - # Best-effort: a failed delete leaves a stale asset, which is harmless. - gh release delete-asset "$RELEASE_TAG" "$asset" --yes || true - done <<< "$STALE" + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + DSYM_ZIP="programa-dSYMs-${EFFECTIVE_BUILD}.zip" + ./scripts/publish_release_candidate.sh \ + --candidate-prefix rolling-candidate- \ + --destination-tag "$PAYLOAD_RELEASE_TAG" \ + --candidate-tag "$PAYLOAD_RELEASE_TAG" \ + --target-sha "${{ github.event.workflow_run.head_sha }}" \ + --seal-output programa-release-candidate.json \ + --build "$EFFECTIVE_BUILD" \ + --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" - name: Cleanup keychain if: always() @@ -670,3 +853,177 @@ jobs: security delete-keychain build.keychain >/dev/null 2>&1 || true rm -f /tmp/cert.p12 rm -f /tmp/programa.provisionprofile + + publish-milestone: + needs: build-sign-notarize + if: >- + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + env: + MILESTONE_TAG: ${{ github.ref_name }} + MILESTONE_TARGET_SHA: ${{ github.sha }} + steps: + - name: Checkout milestone revision + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.sha }} + + - name: Verify immutable milestone checkout + run: | + set -euo pipefail + CHECKED_OUT_SHA="$(git rev-parse HEAD)" + [[ "${CHECKED_OUT_SHA}" == "${MILESTONE_TARGET_SHA}" ]] || { + echo "Milestone publication checkout ${CHECKED_OUT_SHA} does not match event SHA ${MILESTONE_TARGET_SHA}" >&2 + exit 1 + } + + - name: Download milestone release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: programa-milestone-${{ github.run_id }}-${{ github.run_attempt }} + path: milestone-payload + + - name: Verify milestone payload handoff + env: + EFFECTIVE_BUILD: ${{ needs.build-sign-notarize.outputs.effective_build }} + run: | + set -euo pipefail + node - "$EFFECTIVE_BUILD" <<'NODE' + "use strict"; + const path = require("node:path"); + const { + validateMilestonePayloadReferences, + verifyMilestonePayload, + } = require(path.resolve("scripts/milestone_payload.js")); + verifyMilestonePayload({ directory: "milestone-payload", build: process.argv[2] }); + validateMilestonePayloadReferences({ + directory: "milestone-payload", + build: process.argv[2], + repository: process.env.GITHUB_REPOSITORY, + tag: process.env.MILESTONE_TAG, + version: process.env.MILESTONE_TAG.slice(1), + }); + NODE + + - name: Determine milestone monotonic boundary + id: milestone_publication_state + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + EFFECTIVE_BUILD: ${{ needs.build-sign-notarize.outputs.effective_build }} + with: + script: | + core.setOutput('mode', 'strict'); + try { + const release = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: process.env.MILESTONE_TAG, + }); + if (release.data.draft === false) { + // The publisher authenticates the entire permanent release. Skip the + // strict > comparison so an exact published rerun can converge idempotently. + core.setOutput('mode', 'skip'); + } else if (release.data.draft !== true) { + throw new TypeError('Milestone release has an invalid draft state.'); + } else { + const build = process.env.EFFECTIVE_BUILD; + 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', + ]); + const assets = release.data.assets; + if (!Array.isArray(assets)) { + throw new TypeError('Draft milestone release assets are unavailable.'); + } + const names = assets.map((asset) => asset?.name); + if ( + names.length === expectedNames.size && + names.every((name) => expectedNames.has(name)) && + new Set(names).size === expectedNames.size + ) { + // A complete interrupted draft may finalize at the existing public + // high-water. The publisher still authenticates all ten exact bytes. + core.setOutput('mode', 'allow-equal'); + } + } + } catch (error) { + if (error.status === 404) { + return; + } + throw error; + } + + - name: Recheck Sparkle build number before milestone publication + if: steps.milestone_publication_state.outputs.mode != 'skip' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + EFFECTIVE_BUILD: ${{ needs.build-sign-notarize.outputs.effective_build }} + MONOTONIC_MODE: ${{ steps.milestone_publication_state.outputs.mode }} + with: + script: | + const { enforceSparkleMonotonicBuild } = require('./scripts/sparkle_monotonic_guard'); + const effectiveBuild = process.env.MONOTONIC_MODE === 'allow-equal' + ? (BigInt(process.env.EFFECTIVE_BUILD) + 1n).toString() + : process.env.EFFECTIVE_BUILD; + await enforceSparkleMonotonicBuild({ + github, + owner: context.repo.owner, + repo: context.repo.repo, + effectiveBuild, + }); + + - name: Converge milestone release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + EFFECTIVE_BUILD: ${{ needs.build-sign-notarize.outputs.effective_build }} + run: | + set -euo pipefail + ./scripts/publish_milestone_release.sh \ + --tag "$MILESTONE_TAG" \ + --target-sha "$MILESTONE_TARGET_SHA" \ + --build "$EFFECTIVE_BUILD" \ + --payload-dir milestone-payload + + promote-rolling: + needs: build-sign-notarize + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + concurrency: + group: programa-release-publication + queue: max + steps: + - name: Checkout CI-validated revision + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Reconcile highest sealed candidate to rolling + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + EXPECTED_TARGET="${{ github.event.workflow_run.head_sha }}" + CHECKED_OUT_TARGET="$(git rev-parse HEAD)" + [[ "$CHECKED_OUT_TARGET" == "$EXPECTED_TARGET" ]] || { + echo "Checked-out revision ${CHECKED_OUT_TARGET} does not match reconciler target ${EXPECTED_TARGET}" >&2 + exit 1 + } + ./scripts/publish_rolling_release.sh \ + --candidate-prefix rolling-candidate- \ + --rolling-tag rolling \ + --reconciler-target-sha "$EXPECTED_TARGET" diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 02fa8dec..b07f14d4 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -17,7 +17,7 @@ on: record_video: description: Record the virtual display during tests required: false - default: true + default: false type: boolean runner: description: "Runner OS" @@ -207,6 +207,8 @@ jobs: SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" ONLY_TESTING="-only-testing:programaUITests/$TEST_FILTER" DISPLAY_ENV_PREFIX=() + RESULT_BUNDLE_PATH="/tmp/programa-e2e.xcresult" + rm -rf "$RESULT_BUNDLE_PATH" if [ "$TEST_FILTER" = "DisplayResolutionRegressionUITests" ]; then HELPER_PATH="/tmp/create-virtual-display" @@ -256,6 +258,7 @@ jobs: -disableAutomaticPackageResolution -destination "platform=macOS" -maximum-test-execution-time-allowance "$TEST_TIMEOUT" + -resultBundlePath "$RESULT_BUNDLE_PATH" $ONLY_TESTING test ) @@ -292,6 +295,50 @@ jobs: exit 1 fi + - name: Capture E2E diagnostics + if: always() + run: | + set -euo pipefail + DIAGNOSTICS_DIR="/tmp/programa-e2e-diagnostics" + REPORTS_DIR="$DIAGNOSTICS_DIR/DiagnosticReports" + rm -rf "$DIAGNOSTICS_DIR" + mkdir -p "$REPORTS_DIR" + + ps -axo pid=,ppid=,lstart=,state=,comm= \ + | awk 'BEGIN { print "pid ppid start state executable" } /Programa|programa|xctest|XCTRunner|testmanagerd/' \ + > "$DIAGNOSTICS_DIR/processes.txt" + + SYSTEM_REPORTS="$HOME/Library/Logs/DiagnosticReports" + if [ -d "$SYSTEM_REPORTS" ]; then + find "$SYSTEM_REPORTS" -maxdepth 1 -type f -mmin -30 \ + \( -name 'Programa*.crash' -o -name 'Programa*.ips' -o -name 'programa*.crash' -o -name 'programa*.ips' \) \ + -exec cp {} "$REPORTS_DIR/" \; + fi + + PROGRAMA_LOGS_DIR="$HOME/Library/Logs/Programa" + mkdir -p "$DIAGNOSTICS_DIR/ProgramaLogs" + for log_name in diagnostics.log diagnostics.log.1; do + if [ -f "$PROGRAMA_LOGS_DIR/$log_name" ]; then + cp "$PROGRAMA_LOGS_DIR/$log_name" "$DIAGNOSTICS_DIR/ProgramaLogs/$log_name" + fi + done + + if [ -d /tmp/programa-e2e.xcresult ]; then + xcrun xcresulttool get test-results summary \ + --path /tmp/programa-e2e.xcresult \ + > "$DIAGNOSTICS_DIR/xcresult-summary.json" 2>&1 || true + fi + + - name: Upload E2E diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: programa-e2e-diagnostics-${{ github.run_id }} + path: | + /tmp/programa-e2e.xcresult + /tmp/programa-e2e-diagnostics + if-no-files-found: warn + - name: Stop recording and trim if: ${{ always() && inputs.record_video && env.RECORD_PID != '' }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a333c1f..8ac71d83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,21 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Fixed - Provider usage now completes the Codex app-server handshake before reading limits, verifies Claude's current login before trusting its bounded fresh cache, hides signed-out providers, refreshes whenever its compact sidebar control opens, and sizes the popover to its visible content. +- The provider usage popover no longer reports that Claude and Codex usage could not be read: reading the Codex app server's silent stderr through `FileHandle.bytes` blocked Foundation's shared pipe reader, so both probes timed out together. A signed-out Claude CLI now hides the provider instead of showing an error, and the sidebar help and usage icons sit on a slightly wider pitch. - Browser context-menu actions no longer crash when a Google redirect contains repeated query parameters. - Crash recovery no longer opens a second window full of empty workspaces when only some detached terminal sessions can be reattached. The recovery window now contains only live recovered sessions and closes when none recover. - Closing a window now tears down every timer, observer, task, panel, and workspace it owns, so closed windows cannot keep empty workspaces alive or reappear in a later session snapshot. - The title-bar hide-sidebar button now always toggles the sidebar belonging to its own window instead of relying on whichever window was last active. - Provider usage is now available on demand from a sidebar icon and shows every signed-in supported provider, without continuously polling while the popover is closed. - Browser downloads now keep the completed temporary file available when moving it to the destination fails, so a finalization error cannot silently discard the download. +- An ordinary documentation-only commit on `main` can no longer strand the latest green app revision; it now starts CI and can drive the exact-tip release pipeline. +- Browser automation now reuses element references within a page, caps their count and selector bytes, bounds DOM visits and every snapshot payload field, preserves state across successful tab transfers, and finalizes failed transfers or closed tabs, workspaces, and windows exactly once. - Revoking a paired mobile device now also blocks connections still being admitted, and disabling the bridge closes active phone sessions. - An unreadable browser history file no longer causes repeated disk reads on every omnibar keystroke. - Clearing browser history now stays cleared after a temporary disk deletion failure or app termination. +- Escape now cancels in-progress terminal input for Japanese, Chinese, and Korean keyboards even while the command palette is still opening or has just closed. +- Cmd+D now confirms only the close alert in the window that received the shortcut, so another window's alert can no longer close the wrong tab or steal a split command. +- Launching two copies of Programa at nearly the same time now deterministically keeps the newer instance instead of allowing both processes to terminate each other. - Browser imports now treat Unicode domains and their Punycode forms as the same filter, so internationalized domains no longer silently import zero matching cookies or history entries. - Socket automation no longer hangs on split Unicode requests or unsubscribe races, and malformed telemetry can no longer crash the app or grow retained workspace state without bounds. - Large command output no longer deadlocks the CLI or background Git checks, and stalled Git probes now time out instead of accumulating work. diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 4a789b6a..9e343205 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -479,113 +479,99 @@ extension ProgramaCLI { case "pre-tool-use": // Clears "Needs input" status and notification when Claude resumes work // (e.g. after permission grant). Runs async so it doesn't block tool execution. - // - // Wrapped in do/catch like "stop"/"idle": this hook's whole job is clearing the - // blocked indicators, so a teardown-time throw escaping here is precisely the - // case that leaves them stuck on. - do { - let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - - // Deliberately not `try`, for the same reason as the surfaceId resolution - // further down -- and this was the remaining half of that bug. A throw here - // aborted the hook before any of the three clears below, and the catch at the - // bottom treats teardown-shaped errors as benign: it prints "OK", so Claude - // Code sees a perfectly healthy hook while the red "blocked" badge stays lit - // with nothing left to turn it off. - // - // Nothing recovers from that. There is no TTL or watchdog on agent state, and - // AgentScreenDetectionEngine deliberately refuses to touch a surface a hook has - // claimed (its `hooksOwned` guard), so the terminal can be visibly running a - // command while the sidebar still reads "Claude needs your permission" until the - // session ends. - // - // Falling back to the identifiers the Notification hook recorded is the whole - // point: those are the exact workspace and surface it marked blocked, so they - // are the right things to clear even when a live re-resolution is unavailable. - let resolvedWorkspaceId = (try? resolvePreferredWorkspaceIdForClaudeHook( - preferred: mappedSession?.workspaceId, - fallback: workspaceArg, - client: client - )) - ?? nonEmptyClaudeHookIdentifier(mappedSession?.workspaceId).flatMap { isUUID($0) ? $0 : nil } - ?? nonEmptyClaudeHookIdentifier(workspaceArg).flatMap { isUUID($0) ? $0 : nil } + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - // Only when there is genuinely no workspace to name is there nothing to clear. - guard let workspaceId = resolvedWorkspaceId else { - print("OK") - return - } - let claudePid = mappedSession?.pid - - // AskUserQuestion means Claude is about to ask the user something. - // Save question text in session so the Notification handler can use it - // instead of the generic "Claude Code needs your attention". - if let toolName = parsedInput.object?["tool_name"] as? String, - toolName == "AskUserQuestion", - let question = describeAskUserQuestion(parsedInput.object), - let sessionId = parsedInput.sessionId { - // Preserve the existing surfaceId from SessionStart; passing "" - // would overwrite it and cause notifications to target the wrong workspace. - let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? "" - try? sessionStore.upsert( - sessionId: sessionId, - workspaceId: workspaceId, - surfaceId: existingSurfaceId, - cwd: parsedInput.cwd, - lastSubtitle: "Waiting", - lastBody: question - ) - // Don't clear notifications or set status here. - // The Notification hook fires right after and will use the saved question. - print("OK") - return - } + // Deliberately best-effort, for the same reason as the surfaceId resolution + // further down. A throw here used to abort the hook before any of the three + // independent clears below, while Claude Code continued with a stale red + // "blocked" badge that nothing would clear. + // + // Nothing recovers from that. There is no TTL or watchdog on agent state, and + // AgentScreenDetectionEngine deliberately refuses to touch a surface a hook has + // claimed (its `hooksOwned` guard), so the terminal can be visibly running a + // command while the sidebar still reads "Claude needs your permission" until the + // session ends. + // + // Falling back to the identifiers the Notification hook recorded is the whole + // point: those are the exact workspace and surface it marked blocked, so they + // are the right things to clear even when a live re-resolution is unavailable. + let resolvedWorkspaceId = (try? resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + )) + ?? nonEmptyClaudeHookIdentifier(mappedSession?.workspaceId).flatMap { isUUID($0) ? $0 : nil } + ?? nonEmptyClaudeHookIdentifier(workspaceArg).flatMap { isUUID($0) ? $0 : nil } - // Best-effort, and deliberately not `try`: a throw here aborted the whole hook - // before any of the three clears below, stranding the red "blocked" badge lit - // while Claude was already running again. Hook failures don't block tool - // execution, so the user saw Claude working under a permission badge that - // nothing would ever clear. Only reportAgentState needs a surface; the - // notification and status clears are workspace-scoped and must still run. - let surfaceId = try? resolvePreferredSurfaceIdForClaudeHook( - preferred: mappedSession?.surfaceId, - fallback: surfaceArg, + // Only when there is genuinely no workspace to name is there nothing to clear. + guard let workspaceId = resolvedWorkspaceId else { + print("OK") + return + } + let claudePid = mappedSession?.pid + + // AskUserQuestion means Claude is about to ask the user something. + // Save question text in session so the Notification handler can use it + // instead of the generic "Claude Code needs your attention". + if let toolName = parsedInput.object?["tool_name"] as? String, + toolName == "AskUserQuestion", + let question = describeAskUserQuestion(parsedInput.object), + let sessionId = parsedInput.sessionId { + // Preserve the existing surfaceId from SessionStart; passing "" + // would overwrite it and cause notifications to target the wrong workspace. + let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? "" + try? sessionStore.upsert( + sessionId: sessionId, workspaceId: workspaceId, - client: client + surfaceId: existingSurfaceId, + cwd: parsedInput.cwd, + lastSubtitle: "Waiting", + lastBody: question ) + // Don't clear notifications or set status here. + // The Notification hook fires right after and will use the saved question. + print("OK") + return + } - // Clear the badge first: it is the indicator a user reads as "Claude is stuck", - // and it is the only one of the three that can't be re-derived from anything else. - if let surfaceId { - reportAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId, state: .working) - } + // Best-effort, and deliberately not `try`: a throw here aborted the whole hook + // before any of the three clears below, stranding the red "blocked" badge lit + // while Claude was already running again. Hook failures don't block tool + // execution, so the user saw Claude working under a permission badge that + // nothing would ever clear. Only reportAgentState needs a surface; the + // notification and status clears are workspace-scoped and must still run. + let surfaceId = try? resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) - _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + // Clear the badge first: it is the indicator a user reads as "Claude is stuck", + // and it is the only one of the three that can't be re-derived from anything else. + if let surfaceId { + reportAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId, state: .working) + } - let statusValue: String - if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), - let toolStatus = describeToolUse(parsedInput.object) { - statusValue = toolStatus - } else { - statusValue = "Running" - } - // Best-effort: benign if TabManager is already torn down. - try? setClaudeStatus( - client: client, - workspaceId: workspaceId, - value: statusValue, - icon: "bolt.fill", - color: "#4C8DFF", - pid: claudePid - ) - print("OK") - } catch { - if shouldIgnoreClaudeHookTeardownError(error) { - print("OK") - return - } - throw error + _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + + let statusValue: String + if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), + let toolStatus = describeToolUse(parsedInput.object) { + statusValue = toolStatus + } else { + statusValue = "Running" } + // Best-effort: benign if TabManager is already torn down. + try? setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: statusValue, + icon: "bolt.fill", + color: "#4C8DFF", + pid: claudePid + ) + print("OK") case "help", "--help", "-h": print( diff --git a/CLI/programa.swift b/CLI/programa.swift index c7b07481..b3155551 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -3282,21 +3282,22 @@ struct ProgramaCLI { Submit a prompt to an agent surface and wait for it to finish, in one request -- built on surface.wait's agent_state condition (#166). Sends (+ Enter) the same way `send` does, waits up to --working-grace for - the agent to report it started working, then waits up to the remaining - --timeout for it to report idle again. + the agent to report it started working (capped by the remaining --timeout), + then waits up to the remaining --timeout for it to report idle again. - If the agent never reports "working" within --working-grace, the call - resolves immediately (working_observed: false in JSON output) rather than - waiting further -- there's nothing left to usefully watch for. If the - surface never reported any agent_state at all, JSON output carries a - `warning` noting hooks may not be installed. + If the agent never reports "working" before that capped grace expires -- + including when the overall deadline arrives first -- the call resolves + immediately (working_observed: false in JSON output), rather than returning + a timeout error. If the surface never reported any agent_state at all, JSON + output carries a `warning` noting hooks may not be installed. Flags: --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) --surface Target surface (default: $PROGRAMA_SURFACE_ID) --timeout Overall budget for the agent to finish (default: 120) --working-grace How long to wait for a "working" report before - giving up on observing it (default: 3) + giving up on observing it, capped by the remaining + --timeout budget (default: 3) Example: programa prompt-agent "review this diff for bugs" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 74fc75d5..c0c942b1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -775,6 +775,12 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) } +struct ProgramaSingleInstanceProcessKey: Equatable, Sendable { + let startSeconds: Int64 + let startMicroseconds: Int64 + let processIdentifier: pid_t +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate, NSMenuItemValidation { nonisolated(unsafe) static var shared: AppDelegate? @@ -1199,8 +1205,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } else if forceDuplicateLaunchObserver { // Some UI regressions specifically exercise launch-observer behavior while still - // running under XCTest. Allow an explicit opt-in for those cases only. - DispatchQueue.main.async { [weak self] in + // running under XCTest. Give the initial window and accessibility hierarchy a + // bounded head start before opting into process inspection for those cases only. + dilog("single_instance", "pid=\(getpid()) outcome=scheduled reason=ui_test_observer") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + dilog("single_instance", "pid=\(getpid()) outcome=installed reason=ui_test_observer") self?.observeDuplicateLaunches() } } @@ -1522,28 +1531,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // Consume the exact-process arbitration request before synchronous persistence begins. + // Removing the request file acknowledges that this process is responsive, preventing + // the winner's bounded fallback from force-closing us while the snapshot is being saved. + let hasValidatedDuplicateShutdownRequest = consumeValidatedDuplicateShutdownRequest() isTerminatingApp = true SessionMachineryGate.isApplicationTerminating = true // A warning dialog can still cancel this termination request. The final // `applicationWillTerminate` callback is the only point that records a clean exit. _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - return .terminateNow - } - - // If the user already confirmed via the Cmd+Q shortcut warning dialog - // (handleQuitShortcutWarning), skip the check to avoid a second alert. - if isQuitWarningConfirmed { - return .terminateNow - } - - // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via - // the Cmd+Tab app switcher, bypassing handleCustomShortcut. - guard QuitWarningSettings.isEnabled() else { + let shouldWarn = Self.shouldWarnBeforeTermination( + isTaggedDevBuild: SocketControlSettings.isTaggedDevBuild(), + isQuitWarningConfirmed: isQuitWarningConfirmed, + hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, + isQuitWarningEnabled: QuitWarningSettings.isEnabled() + ) + guard shouldWarn else { + let reason = hasValidatedDuplicateShutdownRequest ? "duplicate_request" : "warning_bypassed" + dilog("single_instance", "pid=\(getpid()) outcome=terminate_now reason=\(reason)") return .terminateNow } + dilog("single_instance", "pid=\(getpid()) outcome=warning reason=ordinary_quit") // Show the same confirmation dialog used by the Cmd+Q shortcut path, // then reply asynchronously so we can return .terminateLater now. @@ -2566,6 +2575,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser isApplyingStartupSessionRestore && !includeScrollback } + nonisolated static func performSessionPersistenceWrite( + on queue: DispatchQueue, + synchronously: Bool, + operation: @escaping () -> Void + ) { + if synchronously { + queue.sync(execute: operation) + } else { + queue.async(execute: DispatchWorkItem(block: operation)) + } + } + private func persistSessionSnapshot( _ snapshot: AppSessionSnapshot?, removeWhenEmpty: Bool, @@ -2589,11 +2610,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - if synchronously { - writeBlock() - } else { - sessionPersistenceQueue.async(execute: DispatchWorkItem(block: writeBlock)) - } + Self.performSessionPersistenceWrite( + on: sessionPersistenceQueue, + synchronously: synchronously, + operation: writeBlock + ) } private func buildSessionSnapshot(includeScrollback: Bool, cleanShutdown: Bool = false) -> AppSessionSnapshot? { @@ -3085,19 +3106,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let detachMs = elapsedMs(since: detachStart) let attachStart = ProcessInfo.processInfo.systemUptime #endif - guard destinationWorkspace.attachDetachedSurface( - detached, - inPane: resolvedTargetPane, - atIndex: targetIndex, + let rollbackTarget = detachedSurfaceAttachmentTarget( + workspace: sourceWorkspace, + pane: sourcePane, + index: sourceIndex, focus: focus - ) != nil else { - rollbackDetachedSurface( - detached, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, + ) + let attachmentResult = detached.resolve( + primary: Workspace.DetachedSurfaceAttachmentTarget( + workspace: destinationWorkspace, + paneId: resolvedTargetPane, + index: targetIndex, focus: focus - ) + ), + rollback: rollbackTarget + ) + guard case .attachedPrimary = attachmentResult else { #if DEBUG dlog( "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + @@ -3121,15 +3145,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser orientation: splitTarget.orientation, movingTab: movedTabId, insertFirst: splitTarget.insertFirst - ) != nil else { + ) != nil else { if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { - rollbackDetachedSurface( - detachedFromDestination, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, + if let sourceTarget = detachedSurfaceAttachmentTarget( + workspace: sourceWorkspace, + pane: sourcePane, + index: sourceIndex, focus: focus - ) + ) { + _ = detachedFromDestination.resolve(primary: sourceTarget, rollback: nil) + } else { + detachedFromDestination.finalizePermanently() + } } #if DEBUG dlog( @@ -3941,22 +3968,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed } - private func rollbackDetachedSurface( - _ detached: Workspace.DetachedSurfaceTransfer, - to workspace: Workspace, - sourcePane: PaneID?, - sourceIndex: Int?, + private func detachedSurfaceAttachmentTarget( + workspace: Workspace, + pane: PaneID?, + index: Int?, focus: Bool - ) { - let rollbackPane = sourcePane.flatMap { pane in + ) -> Workspace.DetachedSurfaceAttachmentTarget? { + let resolvedPane = pane.flatMap { pane in workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) } ?? workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first - guard let rollbackPane else { return } - _ = workspace.attachDetachedSurface( - detached, - inPane: rollbackPane, - atIndex: sourceIndex, + guard let resolvedPane else { return nil } + return Workspace.DetachedSurfaceAttachmentTarget( + workspace: workspace, + paneId: resolvedPane, + index: index, focus: focus ) } @@ -6413,8 +6439,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // strictly after the previous one; the first phase that returns wins). Refs #95. // 1. Setup: chord-prefix bookkeeping, Ctrl+D debug probe, close-confirmation-alert // passthrough, modal/sheet passthrough, command-palette window/state computation. - // 2. Palette (highest real precedence): Escape-key routing (palette dismiss / - // suppressed-escape grace window), palette selection-navigation (arrow keys), + // 2. Palette (highest real precedence while interactive): Escape-key routing + // (palette dismiss / terminal-IME bypass / suppressed-escape grace window), + // palette selection-navigation (arrow keys), // palette interactive Return/dismiss handling, stale browser-address-bar-focus // clear, palette "effective" actions (open palette / go-to-workspace + their // chord arming), shouldConsumeShortcutWhileCommandPaletteVisible catch-all. @@ -6526,14 +6553,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), ] - let closeConfirmationPanel = NSApp.windows - .compactMap { $0 as? NSPanel } - .first { panel in - guard panel.isVisible, let root = panel.contentView else { return false } - return closeConfirmationTitles.contains { title in - findStaticText(in: root, equals: title) - } - } + let resolvedEventWindow = resolvedShortcutEventWindow(event) + ?? (event.windowNumber <= 0 ? NSApp.keyWindow : nil) + let closeConfirmationPanel = matchingCloseConfirmationPanel( + in: NSApp.modalWindow, + titles: closeConfirmationTitles + ) ?? matchingCloseConfirmationPanel( + in: resolvedEventWindow, + titles: closeConfirmationTitles + ) ?? matchingCloseConfirmationPanel( + in: resolvedEventWindow?.attachedSheet, + titles: closeConfirmationTitles + ) if let closeConfirmationPanel { // Special-case: Cmd+D should confirm destructive close on alerts. // XCUITest key events often hit the app-level local monitor first, so forward the key @@ -6553,7 +6584,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return false } - if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + if NSApp.modalWindow != nil + || resolvedEventWindow?.sheetParent != nil + || resolvedEventWindow?.attachedSheet != nil { return false } @@ -6572,6 +6605,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser let commandPaletteResponderActiveInTargetWindow = commandPaletteState.isResponderActiveInTargetWindow let commandPaletteInteractiveInTargetWindow = commandPaletteState.isInteractiveInTargetWindow let commandPaletteEffectiveInTargetWindow = commandPaletteState.isEffectiveInTargetWindow + let terminalHasMarkedTextInEventWindow = !normalizedFlags.contains(.command) + && resolvedEventWindow.flatMap { + cmuxOwningGhosttyView(for: $0.firstResponder) + }?.hasMarkedText() == true #if DEBUG if event.keyCode == 36 || event.keyCode == 76 { @@ -6622,6 +6659,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) } #endif + if terminalHasMarkedTextInEventWindow, + !commandPaletteInteractiveInTargetWindow { +#if DEBUG + dlog( + "shortcut.escape terminalImeBypass consumed=0 " + + "target={\(debugWindowToken(resolvedEventWindow))}" + ) +#endif + return false + } if let paletteWindow = escapePaletteWindow, isCommandPaletteEffectivelyVisible(in: paletteWindow) { if commandPaletteMarkedTextInput(in: paletteWindow) != nil { @@ -6801,9 +6848,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // input), don't intercept non-Cmd key events — let them flow through to the // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still // work during composition since Cmd is never part of IME input sequences. - if !normalizedFlags.contains(.command), - let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), - ghosttyView.hasMarkedText() { + if terminalHasMarkedTextInEventWindow { return false } @@ -8467,6 +8512,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return nil } + private func matchingCloseConfirmationPanel( + in window: NSWindow?, + titles: [String] + ) -> NSPanel? { + guard let panel = window as? NSPanel, + panel.isVisible, + let root = panel.contentView, + titles.contains(where: { findStaticText(in: root, equals: $0) }) else { + return nil + } + return panel + } + private func findStaticText(in view: NSView, equals text: String) -> Bool { if let field = view as? NSTextField, field.stringValue == text { return true @@ -8845,26 +8903,428 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } #endif + struct SingleInstanceShutdownRequest: Codable, Equatable, Sendable { + static let currentVersion = 1 + + let version: Int + let targetStartSeconds: Int64 + let targetStartMicroseconds: Int64 + let targetProcessIdentifier: pid_t + let requesterStartSeconds: Int64 + let requesterStartMicroseconds: Int64 + let requesterProcessIdentifier: pid_t + let createdAtUnixSeconds: TimeInterval + + init( + version: Int = Self.currentVersion, + target: ProgramaSingleInstanceProcessKey, + requester: ProgramaSingleInstanceProcessKey, + createdAtUnixSeconds: TimeInterval + ) { + self.version = version + targetStartSeconds = target.startSeconds + targetStartMicroseconds = target.startMicroseconds + targetProcessIdentifier = target.processIdentifier + requesterStartSeconds = requester.startSeconds + requesterStartMicroseconds = requester.startMicroseconds + requesterProcessIdentifier = requester.processIdentifier + self.createdAtUnixSeconds = createdAtUnixSeconds + } + + var target: ProgramaSingleInstanceProcessKey { + ProgramaSingleInstanceProcessKey( + startSeconds: targetStartSeconds, + startMicroseconds: targetStartMicroseconds, + processIdentifier: targetProcessIdentifier + ) + } + + var requester: ProgramaSingleInstanceProcessKey { + ProgramaSingleInstanceProcessKey( + startSeconds: requesterStartSeconds, + startMicroseconds: requesterStartMicroseconds, + processIdentifier: requesterProcessIdentifier + ) + } + } + + nonisolated static func singleInstanceProcessKey( + for processIdentifier: pid_t + ) -> ProgramaSingleInstanceProcessKey? { + var processInfo = kinfo_proc() + var processInfoSize = MemoryLayout.size + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, processIdentifier] + guard sysctl(&mib, UInt32(mib.count), &processInfo, &processInfoSize, nil, 0) == 0, + processInfoSize == MemoryLayout.size, + processInfo.kp_proc.p_pid == processIdentifier else { + return nil + } + + let startTime = processInfo.kp_proc.p_starttime + guard startTime.tv_sec > 0 || startTime.tv_usec > 0 else { return nil } + return ProgramaSingleInstanceProcessKey( + startSeconds: Int64(startTime.tv_sec), + startMicroseconds: Int64(startTime.tv_usec), + processIdentifier: processIdentifier + ) + } + + nonisolated static func shouldTerminateDuplicateInstance( + current: ProgramaSingleInstanceProcessKey, + other: ProgramaSingleInstanceProcessKey + ) -> Bool { + if current.startSeconds != other.startSeconds { + return current.startSeconds > other.startSeconds + } + if current.startMicroseconds != other.startMicroseconds { + return current.startMicroseconds > other.startMicroseconds + } + return current.processIdentifier > other.processIdentifier + } + + private nonisolated static let duplicateShutdownRequestMaxAge: TimeInterval = 10 + private nonisolated static let duplicateShutdownRequestMaxBytes = 4_096 + private nonisolated static let duplicateTerminationGraceInterval: TimeInterval = 2 + + nonisolated static func shouldAcceptDuplicateShutdownRequest( + _ request: SingleInstanceShutdownRequest?, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + resolvedRequesterKey: ProgramaSingleInstanceProcessKey?, + requesterIsProgramaGUI: Bool + ) -> Bool { + guard let request, + request.version == SingleInstanceShutdownRequest.currentVersion, + request.target == currentProcessKey, + request.createdAtUnixSeconds.isFinite else { + return false + } + + let age = now - request.createdAtUnixSeconds + guard age >= 0, age <= duplicateShutdownRequestMaxAge, + request.requester != currentProcessKey, + resolvedRequesterKey == request.requester, + requesterIsProgramaGUI else { + return false + } + + return shouldTerminateDuplicateInstance(current: request.requester, other: currentProcessKey) + } + + nonisolated static func shouldWarnBeforeTermination( + isTaggedDevBuild: Bool, + isQuitWarningConfirmed: Bool, + hasValidatedDuplicateShutdownRequest: Bool, + isQuitWarningEnabled: Bool + ) -> Bool { + guard !isTaggedDevBuild, + !isQuitWarningConfirmed, + !hasValidatedDuplicateShutdownRequest else { + return false + } + return isQuitWarningEnabled + } + + nonisolated static func shouldForceDuplicateTermination( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKey: ProgramaSingleInstanceProcessKey?, + isTerminated: Bool, + requestIsPending: Bool + ) -> Bool { + requestIsPending && resolvedProcessKey == expectedProcessKey && !isTerminated + } + + nonisolated static func shouldConsiderDuplicateApplication( + candidateBundleIdentifier: String?, + candidateProcessIdentifier: pid_t, + candidateExecutableURL: URL?, + expectedBundleIdentifier: String, + currentProcessIdentifier: pid_t, + embeddedCLIURL: URL + ) -> Bool { + guard candidateBundleIdentifier == expectedBundleIdentifier else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=bundle_mismatch") + return false + } + guard candidateProcessIdentifier != currentProcessIdentifier else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=current_process") + return false + } + guard let candidateExecutableURL else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=missing_executable") + return false + } + guard candidateExecutableURL.standardizedFileURL.resolvingSymlinksInPath() + != embeddedCLIURL.standardizedFileURL.resolvingSymlinksInPath() else { + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=ignored reason=embedded_cli") + return false + } + dilog("single_instance", "pid=\(candidateProcessIdentifier) outcome=accepted reason=gui_candidate") + return true + } + + private static func scheduleDuplicateTermination( + requestTermination: () -> Bool, + scheduleGrace: (@escaping @MainActor () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + ) { + guard requestTermination() else { return } + scheduleGrace { + _ = forceTerminationIfStillMatching() + } + } + +#if DEBUG + nonisolated static func shouldAcceptDuplicateShutdownRequestForTesting( + _ request: SingleInstanceShutdownRequest?, + currentProcessKey: ProgramaSingleInstanceProcessKey, + now: TimeInterval, + resolvedRequesterKey: ProgramaSingleInstanceProcessKey?, + requesterIsProgramaGUI: Bool + ) -> Bool { + shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentProcessKey, + now: now, + resolvedRequesterKey: resolvedRequesterKey, + requesterIsProgramaGUI: requesterIsProgramaGUI + ) + } + + nonisolated static func shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: Bool, + isQuitWarningConfirmed: Bool, + hasValidatedDuplicateShutdownRequest: Bool, + isQuitWarningEnabled: Bool + ) -> Bool { + shouldWarnBeforeTermination( + isTaggedDevBuild: isTaggedDevBuild, + isQuitWarningConfirmed: isQuitWarningConfirmed, + hasValidatedDuplicateShutdownRequest: hasValidatedDuplicateShutdownRequest, + isQuitWarningEnabled: isQuitWarningEnabled + ) + } + + nonisolated static func shouldForceDuplicateTerminationForTesting( + expectedProcessKey: ProgramaSingleInstanceProcessKey, + resolvedProcessKey: ProgramaSingleInstanceProcessKey?, + isTerminated: Bool, + requestIsPending: Bool + ) -> Bool { + shouldForceDuplicateTermination( + expectedProcessKey: expectedProcessKey, + resolvedProcessKey: resolvedProcessKey, + isTerminated: isTerminated, + requestIsPending: requestIsPending + ) + } + + static func scheduleDuplicateTerminationForTesting( + requestTermination: () -> Bool, + scheduleGrace: (@escaping @MainActor () -> Void) -> Void, + forceTerminationIfStillMatching: @escaping @MainActor () -> Bool + ) { + scheduleDuplicateTermination( + requestTermination: requestTermination, + scheduleGrace: scheduleGrace, + forceTerminationIfStillMatching: forceTerminationIfStillMatching + ) + } +#endif + + nonisolated private static func duplicateShutdownRequestURL( + for processIdentifier: pid_t + ) -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-single-instance-\(getuid())-\(processIdentifier).json", + isDirectory: false + ) + } + + private static func writeDuplicateShutdownRequest( + target: ProgramaSingleInstanceProcessKey, + requester: ProgramaSingleInstanceProcessKey + ) -> Bool { + let request = SingleInstanceShutdownRequest( + target: target, + requester: requester, + createdAtUnixSeconds: Date().timeIntervalSince1970 + ) + let requestURL = duplicateShutdownRequestURL(for: target.processIdentifier) + do { + let data = try JSONEncoder().encode(request) + guard data.count <= duplicateShutdownRequestMaxBytes else { + dilog("single_instance", "pid=\(target.processIdentifier) outcome=rejected reason=request_too_large") + return false + } + try data.write(to: requestURL, options: .atomic) + dilog("single_instance", "pid=\(target.processIdentifier) outcome=written reason=shutdown_request") + return true + } catch { + dilog("single_instance", "pid=\(target.processIdentifier) outcome=failed reason=request_write") + return false + } + } + + private func consumeValidatedDuplicateShutdownRequest() -> Bool { + let currentProcessIdentifier = getpid() + let requestURL = Self.duplicateShutdownRequestURL(for: currentProcessIdentifier) + guard let fileHandle = try? FileHandle(forReadingFrom: requestURL) else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=missing reason=shutdown_request") + return false + } + defer { + try? fileHandle.close() + try? FileManager.default.removeItem(at: requestURL) + } + + guard let data = try? fileHandle.read(upToCount: Self.duplicateShutdownRequestMaxBytes + 1), + data.count <= Self.duplicateShutdownRequestMaxBytes, + let request = try? JSONDecoder().decode(SingleInstanceShutdownRequest.self, from: data), + let currentKey = Self.singleInstanceProcessKey(for: currentProcessIdentifier), + let bundleIdentifier = Bundle.main.bundleIdentifier else { + dilog("single_instance", "pid=\(currentProcessIdentifier) outcome=rejected reason=malformed_request") + return false + } + + let requesterApplication = NSRunningApplication( + processIdentifier: request.requesterProcessIdentifier + ) + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() + let requesterIsProgramaGUI = requesterApplication.map { application in + Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: application.bundleIdentifier, + candidateProcessIdentifier: application.processIdentifier, + candidateExecutableURL: application.executableURL, + expectedBundleIdentifier: bundleIdentifier, + currentProcessIdentifier: currentProcessIdentifier, + embeddedCLIURL: embeddedCLIURL + ) + } ?? false + let accepted = Self.shouldAcceptDuplicateShutdownRequest( + request, + currentProcessKey: currentKey, + now: Date().timeIntervalSince1970, + resolvedRequesterKey: Self.singleInstanceProcessKey( + for: request.requesterProcessIdentifier + ), + requesterIsProgramaGUI: requesterIsProgramaGUI + ) + dilog( + "single_instance", + "pid=\(currentProcessIdentifier) outcome=\(accepted ? "accepted" : "rejected") reason=shutdown_request" + ) + return accepted + } + + private static func terminateDuplicateApplication( + _ app: NSRunningApplication, + expectedProcessKey: ProgramaSingleInstanceProcessKey, + requesterProcessKey: ProgramaSingleInstanceProcessKey + ) { + let processIdentifier = app.processIdentifier + let requestURL = duplicateShutdownRequestURL(for: processIdentifier) + scheduleDuplicateTermination( + requestTermination: { + guard writeDuplicateShutdownRequest( + target: expectedProcessKey, + requester: requesterProcessKey + ) else { + return false + } + let accepted = app.terminate() + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(accepted ? "requested" : "request_rejected") reason=graceful_terminate" + ) + if !accepted { + try? FileManager.default.removeItem(at: requestURL) + } + return accepted + }, + scheduleGrace: { action in + DispatchQueue.main.asyncAfter(deadline: .now() + duplicateTerminationGraceInterval) { @MainActor in + action() + } + }, + forceTerminationIfStillMatching: { + let requestIsPending = FileManager.default.fileExists(atPath: requestURL.path) + defer { try? FileManager.default.removeItem(at: requestURL) } + guard requestIsPending else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=request_acknowledged") + return false + } + guard let resolvedApplication = NSRunningApplication(processIdentifier: processIdentifier) else { + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=no_longer_running") + return false + } + let resolvedKey = singleInstanceProcessKey(for: processIdentifier) + guard shouldForceDuplicateTermination( + expectedProcessKey: expectedProcessKey, + resolvedProcessKey: resolvedKey, + isTerminated: resolvedApplication.isTerminated, + requestIsPending: requestIsPending + ) else { + let reason = resolvedKey == expectedProcessKey ? "already_terminated" : "identity_changed" + dilog("single_instance", "pid=\(processIdentifier) outcome=skipped reason=\(reason)") + return false + } + + let forced = resolvedApplication.forceTerminate() + dilog( + "single_instance", + "pid=\(processIdentifier) outcome=\(forced ? "forced" : "force_rejected") reason=grace_expired" + ) + return forced + } + ) + } + private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } - let currentPid = ProcessInfo.processInfo.processIdentifier + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() + let currentPid = NSRunningApplication.current.processIdentifier + guard let currentKey = Self.singleInstanceProcessKey(for: currentPid) else { return } for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + guard Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: app.bundleIdentifier, + candidateProcessIdentifier: app.processIdentifier, + candidateExecutableURL: app.executableURL, + expectedBundleIdentifier: bundleId, + currentProcessIdentifier: currentPid, + embeddedCLIURL: embeddedCLIURL + ) else { + continue + } + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), + Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") + continue } + Self.terminateDuplicateApplication( + app, + expectedProcessKey: otherKey, + requesterProcessKey: currentKey + ) } } private func observeDuplicateLaunches() { + guard workspaceObserver == nil else { return } guard let bundleId = Bundle.main.bundleIdentifier else { return } let embeddedCLIURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) .standardizedFileURL .resolvingSymlinksInPath() - let currentPid = ProcessInfo.processInfo.processIdentifier + let currentPid = NSRunningApplication.current.processIdentifier + guard let currentKey = Self.singleInstanceProcessKey(for: currentPid) else { return } workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, @@ -8873,19 +9333,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser ) { [weak self] notification in guard self != nil else { return } guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } - guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } - if let executableURL = app.executableURL? - .standardizedFileURL - .resolvingSymlinksInPath(), - executableURL == embeddedCLIURL { + guard Self.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: app.bundleIdentifier, + candidateProcessIdentifier: app.processIdentifier, + candidateExecutableURL: app.executableURL, + expectedBundleIdentifier: bundleId, + currentProcessIdentifier: currentPid, + embeddedCLIURL: embeddedCLIURL + ) else { return } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() + guard let otherKey = Self.singleInstanceProcessKey(for: app.processIdentifier), + Self.shouldTerminateDuplicateInstance(current: currentKey, other: otherKey) else { + dilog("single_instance", "pid=\(app.processIdentifier) outcome=ignored reason=election") + return + } + MainActor.assumeIsolated { + Self.terminateDuplicateApplication( + app, + expectedProcessKey: otherKey, + requesterProcessKey: currentKey + ) + NSRunningApplication.current.activate(options: [.activateAllWindows]) } - NSRunningApplication.current.activate(options: [.activateAllWindows]) } } diff --git a/Sources/ClaudeQuotaMonitor.swift b/Sources/ClaudeQuotaMonitor.swift index c689efc0..3acbb911 100644 --- a/Sources/ClaudeQuotaMonitor.swift +++ b/Sources/ClaudeQuotaMonitor.swift @@ -224,6 +224,31 @@ enum ClaudeUsageSnapshotParser { } } +/// Streams pipe output through `readabilityHandler` instead of `FileHandle.bytes`. +/// +/// Foundation serves every `FileHandle.bytes` sequence from one shared IO actor +/// that performs blocking reads one at a time. A single idle pipe, such as an +/// app server's silent stderr, therefore starves every other reader in the +/// process, and both provider probes time out together. +enum ProviderUsagePipeReader { + static func chunks(from handle: FileHandle) -> AsyncStream { + AsyncStream { continuation in + handle.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + handle.readabilityHandler = nil + continuation.finish() + } else { + continuation.yield(data) + } + } + continuation.onTermination = { _ in + handle.readabilityHandler = nil + } + } + } +} + struct ClaudeProviderUsageFetcher: ProviderUsageFetching { let provider = ProviderUsageProvider.claude @@ -290,14 +315,14 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { private var isFinished = false private var failed = false - func append(_ byte: UInt8) { + func append(_ chunk: Data) { guard !failed else { return } - guard data.count < ClaudeProviderUsageFetcher.maximumAuthResponseBytes else { + guard data.count + chunk.count <= ClaudeProviderUsageFetcher.maximumAuthResponseBytes else { data.removeAll(keepingCapacity: false) failed = true return } - data.append(byte) + data.append(chunk) } func finish(readFailed: Bool) { @@ -327,14 +352,10 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { let capture = BoundedCapture() let reader = Task { - do { - for try await byte in stdout.fileHandleForReading.bytes { - await capture.append(byte) - } - await capture.finish(readFailed: false) - } catch { - await capture.finish(readFailed: !Task.isCancelled) + for await chunk in ProviderUsagePipeReader.chunks(from: stdout.fileHandleForReading) { + await capture.append(chunk) } + await capture.finish(readFailed: Task.isCancelled) } let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(Int64(max(timeout, 0) * 1_000))) @@ -353,10 +374,10 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { terminate(process) } if !snapshot.isFinished { - try? stdout.fileHandleForReading.close() + reader.cancel() } - reader.cancel() _ = await reader.value + try? stdout.fileHandleForReading.close() guard !Task.isCancelled, snapshot.isFinished, @@ -364,8 +385,9 @@ struct ClaudeProviderUsageFetcher: ProviderUsageFetching { !process.isRunning else { return .failed } - guard process.terminationStatus == 0, - let object = try? JSONSerialization.jsonObject(with: snapshot.data) as? [String: Any], + // The CLI exits non-zero when signed out while still printing + // `{"loggedIn": false}`; a parseable answer beats the exit status. + guard let object = try? JSONSerialization.jsonObject(with: snapshot.data) as? [String: Any], let loggedIn = object["loggedIn"] as? Bool else { return .failed } @@ -751,18 +773,18 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { let process = Process() let stdin = Pipe() let stdout = Pipe() - let stderr = Pipe() process.executableURL = executableURL process.arguments = ["app-server"] process.standardInput = stdin process.standardOutput = stdout - process.standardError = stderr + // Stderr is intentionally discarded; an attached pipe would need its own + // reader, and an idle reader starves the stdout reader (see ProviderUsagePipeReader). + process.standardError = FileHandle.nullDevice do { try process.run() try? stdout.fileHandleForWriting.close() - try? stderr.fileHandleForWriting.close() } catch { return .failed } @@ -771,9 +793,6 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { let responseReader = Task { await collectResponses(from: stdout.fileHandleForReading, into: inbox) } - let stderrReader = Task { - await drain(stderr.fileHandleForReading) - } let clock = ContinuousClock() let deadline = clock.now.advanced(by: .milliseconds(Int64(max(timeout, 0) * 1_000))) var outcome = AppServerOutcome.failed @@ -825,12 +844,9 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { if process.isRunning { terminate(process) } - try? stdout.fileHandleForReading.close() - try? stderr.fileHandleForReading.close() responseReader.cancel() - stderrReader.cancel() _ = await responseReader.value - _ = await stderrReader.value + try? stdout.fileHandleForReading.close() return Task.isCancelled ? .failed : outcome } @@ -846,12 +862,12 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { var line = Data() var receivedBytes = 0 - do { - for try await byte in handle.bytes { + chunks: for await chunk in ProviderUsagePipeReader.chunks(from: handle) { + for byte in chunk { receivedBytes += 1 if receivedBytes > maximumCapturedBytes { await inbox.markExceededCaptureLimit() - break + break chunks } if byte == 0x0A { @@ -861,13 +877,11 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { line.append(byte) } } - if !line.isEmpty, receivedBytes <= maximumCapturedBytes { - await inbox.consume(line) - } - await inbox.finish(readFailed: false) - } catch { - await inbox.finish(readFailed: !Task.isCancelled) } + if !line.isEmpty, receivedBytes <= maximumCapturedBytes { + await inbox.consume(line) + } + await inbox.finish(readFailed: Task.isCancelled) } private static func sanitizedAccountEnvelope(_ envelope: [String: Any]) -> Data? { @@ -891,14 +905,6 @@ struct CodexProviderUsageFetcher: ProviderUsageFetching { return try? JSONSerialization.data(withJSONObject: sanitized) } - private static func drain(_ handle: FileHandle) async { - do { - for try await _ in handle.bytes {} - } catch { - // Stderr is intentionally discarded and never surfaced or stored. - } - } - private static func initializeRequestPayload() throws -> Data { var payload = try JSONSerialization.data(withJSONObject: [ "jsonrpc": "2.0", diff --git a/Sources/ClosedTerminalUndoStore.swift b/Sources/ClosedTerminalUndoStore.swift index cbcc37f8..d6947867 100644 --- a/Sources/ClosedTerminalUndoStore.swift +++ b/Sources/ClosedTerminalUndoStore.swift @@ -17,7 +17,7 @@ import Foundation /// close handler). @MainActor final class ClosedTerminalUndoStore { - static let gracePeriodSeconds: TimeInterval = 5 + nonisolated static let gracePeriodSeconds: TimeInterval = 5 private struct Entry { let id: UUID diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 9935b477..51c240be 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -22,6 +22,54 @@ private func programaRuntimeReadClipboardCallback( GhosttyApp.runtimeReadClipboardCallback(userdata, location, state) } +/// Pinned Ghostty heap-allocates this opaque state for async completion and owns and +/// invalidates it after the corresponding completion. This wrapper never dereferences or frees it. +private struct GhosttyClipboardRequestState: @unchecked Sendable { + let pointer: UnsafeMutableRawPointer +} + +/// Preserves the previous raw-pointer equality semantics without transporting the pointer. +/// It carries the same allocator-reuse/ABA limitation, with no pointer ownership or access. +private struct GhosttyRuntimeSurfaceIdentity: Equatable, Sendable { + let value: UInt + + init(_ surface: ghostty_surface_t) { + value = UInt(bitPattern: surface) + } +} + +/// Transient provenance for Ghostty's synchronous read-to-confirm callback re-entry. +private enum GhosttyClipboardRequestIdentityRegistry { + private static let lock = NSLock() + private static var identities: [UnsafeMutableRawPointer: GhosttyRuntimeSurfaceIdentity] = [:] + + static func withIdentity( + state: GhosttyClipboardRequestState, + identity: GhosttyRuntimeSurfaceIdentity, + body: () throws -> Result + ) rethrows -> Result { + lock.lock() + identities[state.pointer] = identity + lock.unlock() + defer { + lock.lock() + if identities[state.pointer] == identity { + identities.removeValue(forKey: state.pointer) + } + lock.unlock() + } + // Never hold the registry lock while calling Ghostty: confirmation re-enters here. + return try body() + } + + static func identity(for state: GhosttyClipboardRequestState) -> GhosttyRuntimeSurfaceIdentity? { + lock.lock() + let identity = identities[state.pointer] + lock.unlock() + return identity + } +} + // Widened from private to internal: also constructed directly from // TerminalSurface.swift (Nuclear Review #97 split). // @@ -138,7 +186,7 @@ enum GhosttySurfaceUserdataRegistry { /// see the live class reference itself -- there's no way to retain (or read a property of) /// a `GhosttySurfaceCallbackContext` from a callback at all, since `resolve(from:)` never /// dereferences the pointer. -struct GhosttySurfaceCallbackSnapshot { +struct GhosttySurfaceCallbackSnapshot: Sendable { let surfaceId: UUID let tabId: UUID? } @@ -201,129 +249,146 @@ class GhosttyApp { // Only value fields are read here, off-main -- see GhosttySurfaceCallbackContext's // doc comment. All live-object resolution (including the ghostty_surface_t itself) // happens on main below. + guard let state else { return false } + let requestState = GhosttyClipboardRequestState(pointer: state) guard let callbackContext = Self.callbackContext(from: userdata) else { return false } let callbackSurfaceId = callbackContext.surfaceId let callbackTabId = callbackContext.tabId DispatchQueue.main.async { - let terminalSurface = MainActor.assumeIsolated { - Self.resolveTerminalSurface(tabId: callbackTabId, surfaceId: callbackSurfaceId) - } - // Deviation from the pre-fix synchronous behavior: we can no longer tell - // off-main whether the surface is still live, so this callback always - // returns `true` (accepted) below rather than synchronously falling back to - // `false` when the surface is already gone. If resolution fails here, the - // read silently completes as a no-op instead -- ghostty's clipboard-read - // request is simply never fulfilled, matching what already happened when the - // surface went away mid-flight in the old code. - guard let requestSurface: ghostty_surface_t = MainActor.assumeIsolated({ - () -> ghostty_surface_t? in - terminalSurface?.liveSurfaceForGhosttyAccess(reason: "clipboard.read") - }) else { return } - - func completeClipboardRequest(with text: String) { - let finish = { - let currentSurface: ghostty_surface_t? = MainActor.assumeIsolated { - () -> ghostty_surface_t? in - terminalSurface?.liveSurfaceForGhosttyAccess(reason: "clipboard.complete") + MainActor.assumeIsolated { () -> Void in + guard let terminalSurface = Self.resolveTerminalSurface( + tabId: callbackTabId, + surfaceId: callbackSurfaceId + ) else { return } + // Deviation from the pre-fix synchronous behavior: we can no longer tell + // off-main whether the surface is still live, so this callback always + // returns `true` (accepted) below rather than synchronously falling back to + // `false` when the surface is already gone. If resolution fails here, the + // read silently completes as a no-op instead -- ghostty's clipboard-read + // request is simply never fulfilled, matching what already happened when the + // surface went away mid-flight in the old code. + guard let requestSurface = terminalSurface.liveSurfaceForGhosttyAccess( + reason: "clipboard.read" + ) else { return } + let requestSurfaceIdentity = GhosttyRuntimeSurfaceIdentity(requestSurface) + + func completeClipboardRequest(with text: String) { + let finish = { + MainActor.assumeIsolated { () -> Void in + guard let currentSurface = Self.resolveLiveSurface( + tabId: callbackTabId, + surfaceId: callbackSurfaceId, + reason: "clipboard.complete" + ) else { return } + let currentSurfaceIdentity = GhosttyRuntimeSurfaceIdentity(currentSurface) + guard currentSurfaceIdentity == requestSurfaceIdentity else { + return + } + text.withCString { ptr in + GhosttyClipboardRequestIdentityRegistry.withIdentity( + state: requestState, + identity: currentSurfaceIdentity + ) { + ghostty_surface_complete_clipboard_request( + currentSurface, + ptr, + requestState.pointer, + false + ) + } + } + } } - guard currentSurface == requestSurface else { return } - text.withCString { ptr in - ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false) + if Thread.isMainThread { + finish() + } else { + DispatchQueue.main.async(execute: finish) } } - if Thread.isMainThread { - finish() - } else { - DispatchQueue.main.async(execute: finish) - } - } - guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else { - completeClipboardRequest(with: "") - return - } + guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else { + completeClipboardRequest(with: "") + return + } - let preparedContent = TerminalImageTransferPlanner.prepare( - pasteboard: pasteboard, - mode: .paste - ) + let preparedContent = TerminalImageTransferPlanner.prepare( + pasteboard: pasteboard, + mode: .paste + ) - switch preparedContent { - case .reject: - completeClipboardRequest(with: "") - case .insertText(let text): - completeClipboardRequest(with: text) - case .fileURLs(let fileURLs): - let operation = TerminalImageTransferOperation() - MainActor.assumeIsolated { - terminalSurface?.hostedView.beginImageTransferIndicator( + switch preparedContent { + 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 = MainActor.assumeIsolated { - terminalSurface?.resolvedImageTransferTarget() ?? .local - } - let plan = TerminalImageTransferPlanner.plan( - fileURLs: fileURLs, - target: target - ) + 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) + 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 } - ) - }, - insertText: { text in - MainActor.assumeIsolated { - terminalSurface?.hostedView.endImageTransferIndicator( - for: operation + workspace.uploadDroppedFilesForRemoteTerminal( + fileURLs, + operation: operation, + completion: { result in + finish(result) + GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) + } ) - } - completeClipboardRequest(with: text) - }, - onFailure: { _ in - MainActor.assumeIsolated { - terminalSurface?.hostedView.endImageTransferIndicator( - for: operation + }, + uploadDetectedSSH: { session, fileURLs, operation, finish in + session.uploadDroppedFiles( + fileURLs, + operation: operation, + completion: { result in + finish(result) + GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs) + } ) - } - NSSound.beep() + }, + 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))") + dlog("terminal.remotePasteUpload.failed surface=\(callbackSurfaceId.uuidString.prefix(5))") #endif - completeClipboardRequest(with: "") - } - ) + completeClipboardRequest(with: "") + } + ) + } } } @@ -433,34 +498,40 @@ class GhosttyApp { runtimeConfig.action_cb = { app, target, action in return GhosttyApp.shared.handleAction(target: target, action: action) } - // Some GhosttyKit builds import this callback as returning `Void` in Swift even - // though the C ABI returns `bool`. Store the C-compatible shim explicitly so the - // project compiles against both importer variants. - runtimeConfig.read_clipboard_cb = unsafeBitCast( - programaRuntimeReadClipboardCallback as @convention(c) ( - UnsafeMutableRawPointer?, - ghostty_clipboard_e, - UnsafeMutableRawPointer? - ) -> Bool, - to: ghostty_runtime_read_clipboard_cb.self - ) + runtimeConfig.read_clipboard_cb = programaRuntimeReadClipboardCallback runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in guard let content else { return } + guard let state else { return } + let requestState = GhosttyClipboardRequestState(pointer: state) guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return } let callbackSurfaceId = callbackContext.surfaceId let callbackTabId = callbackContext.tabId + guard let originatingSurfaceIdentity = GhosttyClipboardRequestIdentityRegistry.identity( + for: requestState + ) else { return } // Snapshot the C string now -- `content` is only valid for the duration of // this callback invocation, and resolving the live surface requires a // main-thread hop (see GhosttySurfaceCallbackContext's doc comment). let contentString = String(cString: content) DispatchQueue.main.async { - guard let surface: ghostty_surface_t = MainActor.assumeIsolated({ - () -> ghostty_surface_t? in - GhosttyApp.resolveLiveSurface(tabId: callbackTabId, surfaceId: callbackSurfaceId, reason: "clipboard.confirm") - }) else { return } - contentString.withCString { ptr in - ghostty_surface_complete_clipboard_request(surface, ptr, state, true) + MainActor.assumeIsolated { () -> Void in + guard let surface = GhosttyApp.resolveLiveSurface( + tabId: callbackTabId, + surfaceId: callbackSurfaceId, + reason: "clipboard.confirm" + ) else { return } + guard GhosttyRuntimeSurfaceIdentity(surface) == originatingSurfaceIdentity else { + return + } + contentString.withCString { ptr in + ghostty_surface_complete_clipboard_request( + surface, + ptr, + requestState.pointer, + true + ) + } } } } diff --git a/Sources/MobileBridge/MobileBridgeSession.swift b/Sources/MobileBridge/MobileBridgeSession.swift index 5288e7a3..a6f10730 100644 --- a/Sources/MobileBridge/MobileBridgeSession.swift +++ b/Sources/MobileBridge/MobileBridgeSession.swift @@ -151,7 +151,7 @@ enum MobileBridgeSession { // after handing it off. let remoteFD = pipe.remoteFD Thread.detachNewThread { - TerminalController.shared.handleClient(remoteFD, peerPid: getpid(), ignoresListenerState: true) + TerminalController.shared.handleClient(remoteFD, peerPid: getpid(), source: .mobileBridge) } await pump( diff --git a/Sources/Panels/BrowserPanel+Automation.swift b/Sources/Panels/BrowserPanel+Automation.swift index 6c5cff63..4c6aadef 100644 --- a/Sources/Panels/BrowserPanel+Automation.swift +++ b/Sources/Panels/BrowserPanel+Automation.swift @@ -30,7 +30,7 @@ extension BrowserPanel { } /// Take a snapshot of the web view - func takeSnapshot(completion: @escaping (NSImage?) -> Void) { + func takeSnapshot(completion: @escaping @MainActor (NSImage?) -> Void) { let config = WKSnapshotConfiguration() webView.takeSnapshot(with: config) { image, error in if let error = error { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e0c662e3..fdbb579f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1868,9 +1868,6 @@ final class BrowserPanel: Panel, ObservableObject { configuration: WKWebViewConfiguration, windowFeatures: WKWindowFeatures ) -> WKWebView? { - // Share the opener's process pool so popups (e.g. OAuth flows) participate in the - // same renderer/process group as the opener rather than defaulting to a fresh one. - configuration.processPool = webView.configuration.processPool let controller = BrowserPopupWindowController( configuration: configuration, windowFeatures: windowFeatures, diff --git a/Sources/ReviewDiffParser.swift b/Sources/ReviewDiffParser.swift index 8369553f..6ef4bee1 100644 --- a/Sources/ReviewDiffParser.swift +++ b/Sources/ReviewDiffParser.swift @@ -2,26 +2,26 @@ import Foundation /// Why a file's content isn't rendered as a line-by-line diff. See /// docs/plans/diff-review-panel.md §3 "Binary detection" / "Size cap". -enum ReviewNotDiffableReason: Equatable { +enum ReviewNotDiffableReason: Equatable, Sendable { case binary case tooLarge(sizeBytes: Int64) case newUntrackedFile } -enum ReviewFileDiffStatus: String, Equatable { +enum ReviewFileDiffStatus: String, Equatable, Sendable { case added case modified case deleted case renamed } -enum ReviewDiffLineKind: Equatable { +enum ReviewDiffLineKind: Equatable, Sendable { case context case addition case deletion } -struct ReviewDiffLine: Equatable { +struct ReviewDiffLine: Equatable, Sendable { let kind: ReviewDiffLineKind /// 1-based line number in the pre-image (`nil` for pure additions). let oldLineNumber: Int? @@ -31,12 +31,12 @@ struct ReviewDiffLine: Equatable { let text: String } -struct ReviewHunk: Equatable { +struct ReviewHunk: Equatable, Sendable { let header: String let lines: [ReviewDiffLine] } -struct ReviewFileDiff: Identifiable, Equatable { +struct ReviewFileDiff: Identifiable, Equatable, Sendable { var id: String { newPath ?? oldPath ?? "unknown" } let oldPath: String? let newPath: String? diff --git a/Sources/ReviewDiffProber.swift b/Sources/ReviewDiffProber.swift index fc448c60..47a3bfe5 100644 --- a/Sources/ReviewDiffProber.swift +++ b/Sources/ReviewDiffProber.swift @@ -1,7 +1,7 @@ import Foundation /// Which base the review panel diffs the worktree against. -enum ReviewDiffMode: String, Codable, Equatable { +enum ReviewDiffMode: String, Codable, Equatable, Sendable { /// Worktree vs `HEAD`, including uncommitted + untracked changes. case uncommitted /// `HEAD` vs the merge-base with a base branch (default `origin/main`, falling back to @@ -9,12 +9,12 @@ enum ReviewDiffMode: String, Codable, Equatable { case branch } -enum ReviewDiffError: Equatable { +enum ReviewDiffError: Equatable, Sendable { case notGitRepository case unknownBaseBranch(String) } -struct ReviewDiffSnapshot: Equatable { +struct ReviewDiffSnapshot: Equatable, Sendable { var files: [ReviewFileDiff] = [] var generatedAt: Date = Date() var repositoryRoot: String? diff --git a/Sources/SessionEscrow.swift b/Sources/SessionEscrow.swift index 96e748f0..4bb279e9 100644 --- a/Sources/SessionEscrow.swift +++ b/Sources/SessionEscrow.swift @@ -526,7 +526,7 @@ enum UnixDomainFDPassing { withUnsafeMutableBytes(of: &addr.sun_path) { rawPath in guard let base = rawPath.baseAddress else { return } memset(base, 0, rawPath.count) - path.withCString { cstr in + _ = path.withCString { cstr in memcpy(base, cstr, path.utf8.count) } } diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift index 1d47692b..cb8c44db 100644 --- a/Sources/SidebarVisuals.swift +++ b/Sources/SidebarVisuals.swift @@ -32,7 +32,9 @@ struct SidebarFooter: View { enum SidebarFooterControlLayout { static let buttonSize: CGFloat = 44 - static let visualPitch: CGFloat = 20 + /// Traffic lights sit on a 20pt pitch; the thinner outline glyphs need one + /// extra grid step to read as evenly spaced next to them. + static let visualPitch: CGFloat = 24 static func helpIconOffset(clustersWithUsage: Bool) -> CGFloat { clustersWithUsage ? (buttonSize - visualPitch) / 2 : 0 diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 045de2b8..7eeff404 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -4,7 +4,7 @@ import Foundation import Security #endif -enum SocketControlMode: String, CaseIterable, Identifiable { +enum SocketControlMode: String, CaseIterable, Identifiable, Sendable { case off case cmuxOnly case automation diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index 497be949..3ccf8d48 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -50,7 +50,8 @@ struct TabItemView: View, Equatable { private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) // Closures, Bindings, and object references are excluded from == - // because they're recreated every parent eval but don't affect rendering. + // because they're recreated every parent eval. Render-affecting binding state + // is supplied separately through immutable snapshots below. nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { lhs.tab === rhs.tab && lhs.index == rhs.index && @@ -69,13 +70,10 @@ struct TabItemView: View, Equatable { lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && lhs.settings == rhs.settings && lhs.showsWorktreeBadge == rhs.showsWorktreeBadge && - // Bindings are normally excluded (recreated per parent eval, don't - // affect rendering) — but body READS these two values (isBeingDragged - // opacity dim, showsCenteredTopDropIndicator), so excluding them froze - // drag visuals mid-drag (audit 2026-08-20, H2). Compare wrapped values; - // only drag interactions churn them, never typing. - lhs.draggedTabId == rhs.draggedTabId && - lhs.dropIndicator == rhs.dropIndicator + // Keep these immutable render snapshots last so `==` and body consume + // the same drag state without reading Binding storage during typing. + lhs.draggedTabIdSnapshot == rhs.draggedTabIdSnapshot && + lhs.dropIndicatorSnapshot == rhs.dropIndicatorSnapshot } // Use plain references instead of @EnvironmentObject to avoid subscribing @@ -99,6 +97,9 @@ struct TabItemView: View, Equatable { @Binding var lastSidebarSelectionIndex: Int? let showsModifierShortcutHints: Bool let dragAutoScrollController: SidebarDragAutoScrollController + let draggedTabIdSnapshot: UUID? + let dropIndicatorSnapshot: SidebarDropIndicator? + // Mutation-only bindings; body rendering uses the immutable snapshots above. @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? let contextMenuWorkspaceIds: [UUID] @@ -126,7 +127,7 @@ struct TabItemView: View, Equatable { } private var isBeingDragged: Bool { - draggedTabId == tab.id + draggedTabIdSnapshot == tab.id } private var sidebarShortcutHintXOffset: Double { @@ -1217,7 +1218,7 @@ struct TabItemView: View, Equatable { } private var showsCenteredTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } + guard draggedTabIdSnapshot != nil, let indicator = dropIndicatorSnapshot else { return false } if indicator.tabId == tab.id && indicator.edge == .top { return true } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index cbbecec3..8b9d2a4e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -799,7 +799,10 @@ class TabManager: ObservableObject { self?.recentlyClosedBrowsers.push(snapshot) } workspace.onTerminalCloseStagedForUndo = { [weak self, weak workspace] transfer, paneId, index in - guard let self, let workspace else { return } + guard let self, let workspace else { + transfer.finalizePermanently() + return + } self.stageDetachedTerminalTransferForUndo( transfer, originalWorkspaceId: workspace.id, @@ -866,13 +869,7 @@ class TabManager: ObservableObject { ) }, finalize: { - // Mirrors Workspace+Bonsplit.swift's `didCloseTab` non-detaching teardown: release - // the SSH control master this transfer was keeping alive (if any), then close the - // retained panel for real. - if let cleanupConfiguration = transfer.remoteCleanupConfiguration { - Workspace.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - } - transfer.panel.close() + transfer.finalizePermanently() } ) } @@ -887,15 +884,8 @@ class TabManager: ObservableObject { originalPaneId: PaneID?, originalIndex: Int? ) { - func giveUp() { - if let cleanupConfiguration = transfer.remoteCleanupConfiguration { - Workspace.requestSSHControlMasterCleanupIfNeeded(configuration: cleanupConfiguration) - } - transfer.panel.close() - } - guard let targetWorkspace = workspace(withId: originalWorkspaceId) ?? selectedWorkspace ?? tabs.first else { - giveUp() + transfer.finalizePermanently() return } @@ -907,7 +897,7 @@ class TabManager: ObservableObject { } guard let targetPane else { - giveUp() + transfer.finalizePermanently() return } @@ -917,7 +907,15 @@ class TabManager: ObservableObject { let tabCount = targetWorkspace.bonsplitController.tabs(inPane: targetPane).count let clampedIndex = originalIndex.map { min(max($0, 0), tabCount) } - targetWorkspace.attachDetachedSurface(transfer, inPane: targetPane, atIndex: clampedIndex, focus: true) + _ = transfer.resolve( + primary: Workspace.DetachedSurfaceAttachmentTarget( + workspace: targetWorkspace, + paneId: targetPane, + index: clampedIndex, + focus: true + ), + rollback: nil + ) } /// Canonical workspace lookup by ID. All workspace-by-ID scans across TabManager and its @@ -2992,7 +2990,7 @@ class TabManager: ObservableObject { // workspace selection can fire on the next run loop turn and clobber this panel // with its stale captured panel ID. if let targetPanelId, let panel = tab.panels[targetPanelId] { - focusTransitionCoordinator.beginTransition( + _ = focusTransitionCoordinator.beginTransition( to: FocusTransitionCoordinator.Owner( workspaceID: tabId, panelID: targetPanelId, diff --git a/Sources/TerminalController+AgentDetection.swift b/Sources/TerminalController+AgentDetection.swift index 87a87b4b..71be2a78 100644 --- a/Sources/TerminalController+AgentDetection.swift +++ b/Sources/TerminalController+AgentDetection.swift @@ -12,7 +12,7 @@ import Foundation extension TerminalController { // MARK: - V2 Agent Detection Methods - func v2AgentDetectionList(params: [String: Any]) -> V2CallResult { + nonisolated func v2AgentDetectionList(params: [String: Any]) -> V2CallResult { // Re-read from disk so a manifest just written by `agent-detection scaffold` shows up // without relaunching the app. AgentManifestLoader.shared.reloadFromDisk() @@ -50,11 +50,13 @@ extension TerminalController { return .ok(["manifests": payloads]) } - func v2AgentDetectionClassify(params: [String: Any]) -> V2CallResult { + nonisolated func v2AgentDetectionClassify(params: [String: Any]) -> V2CallResult { // Same reason as `v2AgentDetectionList`: `test` exists to check patterns you just // edited, so it must read the file as it is on disk right now. AgentManifestLoader.shared.reloadFromDisk() - let readResult = v2SurfaceReadText(params: params) + let readResult = v2MainSync { + v2SurfaceReadText(params: params) + } let textPayload: [String: Any] switch readResult { case .ok(let value): diff --git a/Sources/TerminalController+AgentPrompt.swift b/Sources/TerminalController+AgentPrompt.swift index d5f12321..13f3b3e9 100644 --- a/Sources/TerminalController+AgentPrompt.swift +++ b/Sources/TerminalController+AgentPrompt.swift @@ -9,16 +9,17 @@ // that instant and register a watcher for the next "working" transition. This closes the // race where a hook reacts to the injected text before a separately-registered watcher // would exist (same atomic check+register pattern as surface.wait). -// 2. Grace window (`working_grace_ms`, default 3000): wait for the "working" transition. +// 2. Grace window (`working_grace_ms`, default 3000), capped by the remaining overall +// `timeout_ms` budget: wait for the "working" transition. // - If observed, proceed to step 3. -// - If the grace window elapses without ever seeing "working", there is nothing further -// useful to wait for -- resolve immediately using whatever the surface's agent_state -// already is (`working_observed: false`). This is deliberately not a hard error: a -// prompt can finish faster than the grace window, or the hook simply may not fire for a -// trivial prompt. If the surface never reported ANY agent_state at all (neither before -// sending nor during the grace window), the response carries a `warning` field instead -// of silently succeeding, since that combination usually means agent hooks were never -// installed for this surface. +// - If the grace window or overall deadline elapses without ever seeing "working", there is +// nothing further useful to wait for -- resolve immediately using whatever the surface's +// agent_state already is (`working_observed: false`). This is deliberately not a hard +// error: a prompt can finish faster than the grace window, or the hook simply may not fire +// for a trivial prompt. If the surface never reported ANY agent_state at all (neither +// before sending nor during the grace window), the response carries a `warning` field +// instead of silently succeeding, since that combination usually means agent hooks were +// never installed for this surface. // 3. Once "working" is observed, wait (for the remaining overall `timeout_ms` budget) for the // surface's agent_state to reach "idle" (or clear entirely -- see surface.wait's no-state // rule) and resolve with `working_observed: true`. @@ -30,8 +31,9 @@ extension TerminalController { /// `agent.prompt`: send `text` to an agent surface and block (single request/response) until /// the agent finishes, per the phased semantics documented on this file and in /// docs/v2-api-migration.md. - func v2AgentPrompt(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { + nonisolated func v2AgentPrompt(params: [String: Any]) -> V2CallResult { + let tabManagerAvailable = v2MainSync { self.v2ResolveTabManager(params: params) != nil } + guard tabManagerAvailable else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } guard let rawText = params["text"] as? String, !rawText.isEmpty else { @@ -71,6 +73,10 @@ extension TerminalController { var resolvedState: AgentActivityState? v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + setupError = .err(code: "unavailable", message: "TabManager not available", data: nil) + return + } guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { setupError = .err(code: "not_found", message: "Workspace not found", data: nil) return @@ -167,14 +173,17 @@ extension TerminalController { } // Step 2: grace window for a "working" transition. - let observedWorking = workingSemaphore.wait(timeout: .now() + Double(workingGraceMs) / 1000.0) == .success + let remaining = max(0, totalDeadline.timeIntervalSinceNow) + let graceWait = min(Double(workingGraceMs) / 1000.0, remaining) + let observedWorking = workingSemaphore.wait(timeout: .now() + graceWait) == .success if !observedWorking { AgentStateWaitRegistry.shared.removeWaiter(surfaceId: surfaceIdOut, token: firstPhaseWaiterToken) var currentState: AgentActivityState? var stateReadError: V2CallResult? v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) ?? self.tabManager, + let ws = tabManager.tabs.first(where: { $0.id == workspaceId }) else { stateReadError = .err(code: "not_found", message: "Workspace not found", data: nil) return } @@ -195,7 +204,8 @@ extension TerminalController { var idleWaiterToken: UUID? var idleSetupError: V2CallResult? v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) ?? self.tabManager, + let ws = tabManager.tabs.first(where: { $0.id == workspaceId }) else { idleSetupError = .err(code: "not_found", message: "Workspace not found", data: nil) return } diff --git a/Sources/TerminalController+BrowserAutomation.swift b/Sources/TerminalController+BrowserAutomation.swift index 97f7296a..3a6abc48 100644 --- a/Sources/TerminalController+BrowserAutomation.swift +++ b/Sources/TerminalController+BrowserAutomation.swift @@ -4,55 +4,31 @@ import Carbon.HIToolbox import Darwin @preconcurrency import Foundation import Bonsplit +import os import WebKit -@MainActor -private final class BrowserDownloadWaitState { - let surfaceId: UUID - let finish: ([String: Any]) -> Void - var observer: NSObjectProtocol? - private var completed = false +extension TerminalController { + // MARK: - V2 Browser Methods - init(surfaceId: UUID, finish: @escaping ([String: Any]) -> Void) { - self.surfaceId = surfaceId - self.finish = finish - } + static let v2BrowserSnapshotRawEntryLimit = 4_096 + static let v2BrowserSnapshotTextInspectionLimit = 1_048_832 - func receive(_ note: Notification) { - guard !completed else { return } - guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, - candidateSurfaceId == surfaceId, - let event = note.userInfo?["event"] as? [String: Any] else { - return - } - completed = true - if let observer { - NotificationCenter.default.removeObserver(observer) - self.observer = nil - } - finish(event) + enum V2BrowserSnapshotCollectionOutcome { + case collected([String: Any]) + case frameUnavailable(String) + case failed(String) } - func install(_ observer: NSObjectProtocol) { - if completed { - NotificationCenter.default.removeObserver(observer) - } else { - self.observer = observer - } + enum V2BrowserGeneratedSelectorAction { + case click } - func cancel() { - guard !completed else { return } - completed = true - if let observer { - NotificationCenter.default.removeObserver(observer) - self.observer = nil - } + enum V2BrowserGeneratedSelectorActionOutcome { + case succeeded + case frameUnavailable(String) + case elementNotFound + case failed(String) } -} - -extension TerminalController { - // MARK: - V2 Browser Methods enum V2BrowserFrameSelectorSource { case frameSelect @@ -349,6 +325,7 @@ extension TerminalController { ) } + return prepare(data: data, limits: limits) } @@ -357,7 +334,6 @@ extension TerminalController { limits: V2BrowserStateRestoreLimits = .standard ) -> Result { let byteLimit = max(0, limits.documentByteLimit) - guard data.count <= byteLimit else { return .failure( V2BrowserStateRestoreFailure( @@ -566,6 +542,7 @@ extension TerminalController { } } + static func execute( _ state: PreparedState, using operations: V2BrowserStateRestoreOperations @@ -839,9 +816,172 @@ extension TerminalController { } } - func v2BrowserWithPanel( + private enum V2BrowserJavaScriptExecutionOutcome { + case completed(Any?) + case frameUnavailable(String) + case failed(String) + } + + private struct V2BrowserScreenshotContext: Sendable { + let workspaceId: UUID + let surfaceId: UUID + } + + private enum V2BrowserScreenshotStartFailure: Sendable { + case tabManagerUnavailable + case workspaceNotFound + case noFocusedSurface + case surfaceNotBrowser(UUID) + } + + private enum V2BrowserScreenshotStartOutcome: Sendable { + case started(V2BrowserScreenshotContext) + case failed(V2BrowserScreenshotStartFailure) + } + + private enum V2BrowserScreenshotCaptureOutcome: Sendable { + case captured(Data) + case failed + case timedOut + } + + private enum V2BrowserScreenshotWaitPhase: Sendable { + case pending + case completed(V2BrowserScreenshotCaptureOutcome) + } + + private final class V2BrowserScreenshotWaitState: Sendable { + private let phase = OSAllocatedUnfairLock(initialState: V2BrowserScreenshotWaitPhase.pending) + private let completionSemaphore = DispatchSemaphore(value: 0) + + func complete(_ outcome: V2BrowserScreenshotCaptureOutcome) { + let shouldSignal = phase.withLock { phase in + guard case .pending = phase else { return false } + phase = .completed(outcome) + return true + } + if shouldSignal { + completionSemaphore.signal() + } + } + + func wait(timeout: TimeInterval) -> V2BrowserScreenshotCaptureOutcome { + _ = completionSemaphore.wait(timeout: .now() + timeout) + return phase.withLock { phase in + switch phase { + case .pending: + phase = .completed(.timedOut) + return .timedOut + case .completed(let outcome): + return outcome + } + } + } + } + + private struct V2BrowserScreenshotDebugGate: Sendable { + let pendingMarkerPath: String + let releaseMarkerPath: String + } + + private struct V2BrowserDownloadPathContext: Sendable { + let workspaceId: UUID + let surfaceId: UUID + } + + private enum V2BrowserDownloadPathLookupFailure: Sendable { + case tabManagerUnavailable + case workspaceNotFound + case noFocusedSurface + case surfaceNotBrowser(UUID) + } + + private enum V2BrowserDownloadPathLookup: Sendable { + case resolved(V2BrowserDownloadPathContext) + case failed(V2BrowserDownloadPathLookupFailure) + } + + private enum V2BrowserDownloadPathWaitResult: Sendable { + case ready + case timedOut + case failedToWatch + } + + // SAFETY: Every mutable field is accessed only while `lock` is held. The immutable + // semaphores provide the completion and cancellation acknowledgements across queues. + private final class V2BrowserDownloadPathWaitState: @unchecked Sendable { + private let lock = NSLock() + private let completionSemaphore = DispatchSemaphore(value: 0) + private let cancellationSemaphore = DispatchSemaphore(value: 0) + private var source: DispatchSourceFileSystemObject? + private var fileDescriptor: Int32? + private var result: Bool? + private var cancellationAcknowledged = false + + init(fileDescriptor: Int32) { + self.fileDescriptor = fileDescriptor + } + + func install(source: DispatchSourceFileSystemObject) { + lock.lock() + self.source = source + lock.unlock() + } + + func finish(ready: Bool) { + let sourceToCancel: DispatchSourceFileSystemObject? + lock.lock() + guard result == nil else { + lock.unlock() + return + } + result = ready + sourceToCancel = source + lock.unlock() + + sourceToCancel?.cancel() + completionSemaphore.signal() + } + + func waitForResult() -> Bool { + completionSemaphore.wait() + lock.lock() + defer { lock.unlock() } + return result ?? false + } + + func closeFileDescriptorAndAcknowledgeCancellation() { + let descriptorToClose: Int32? + let shouldSignal: Bool + lock.lock() + if cancellationAcknowledged { + descriptorToClose = nil + shouldSignal = false + } else { + cancellationAcknowledged = true + descriptorToClose = fileDescriptor + fileDescriptor = nil + source = nil + shouldSignal = true + } + lock.unlock() + + if let descriptorToClose { + Darwin.close(descriptorToClose) + } + if shouldSignal { + cancellationSemaphore.signal() + } + } + + func waitForCancellationAcknowledgement() { + cancellationSemaphore.wait() + } + } + + nonisolated func v2BrowserWithPanel( params: [String: Any], - _ body: (_ tabManager: TabManager, _ workspace: Workspace, _ surfaceId: UUID, _ browserPanel: BrowserPanel) -> V2CallResult + _ body: @MainActor (_ tabManager: TabManager, _ workspace: Workspace, _ surfaceId: UUID, _ browserPanel: BrowserPanel) -> V2CallResult ) -> V2CallResult { var result: V2CallResult = .err(code: "internal_error", message: "Browser operation failed", data: nil) v2MainSync { @@ -867,7 +1007,7 @@ extension TerminalController { return result } - private func v2JSONLiteral(_ value: Any) -> String { + private nonisolated func v2JSONLiteral(_ value: Any) -> String { if let data = try? JSONSerialization.data(withJSONObject: [value], options: []), let text = String(data: data, encoding: .utf8), text.count >= 2 { @@ -1100,14 +1240,14 @@ extension TerminalController { } } - func v2BrowserSelector(_ params: [String: Any]) -> String? { + nonisolated func v2BrowserSelector(_ params: [String: Any]) -> String? { v2String(params, "selector") ?? v2String(params, "sel") ?? v2String(params, "element_ref") ?? v2String(params, "ref") } - func v2BrowserNotSupported(_ method: String, details: String) -> V2CallResult { + nonisolated func v2BrowserNotSupported(_ method: String, details: String) -> V2CallResult { .err(code: "not_supported", message: "\(method) is not supported on WKWebView", data: ["details": details]) } @@ -1117,22 +1257,430 @@ extension TerminalController { v2BrowserNavigationGenerationBySurface[surfaceId] ?? 0 } - /// Invalidates every element ref allocated on the surface's previous page by advancing its - /// navigation generation (M6a). Call from the single main-frame-commit choke point only — - /// do not call per-subframe or per-provisional-navigation event. + /// Advances the surface to a new page while retaining only the page that just ended. Refs + /// from that immediately previous generation remain diagnosable as stale; older refs are + /// discarded. Call from the single main-frame-commit choke point only. func v2BrowserBumpNavigationGeneration(forSurface surfaceId: UUID) { - v2BrowserNavigationGenerationBySurface[surfaceId, default: 0] += 1 + let previousGeneration = v2BrowserNavigationGeneration(forSurface: surfaceId) + let ownedTokens = v2BrowserElementRefTokensBySurface[surfaceId] ?? [] + var retainedTokens: Set = [] + retainedTokens.reserveCapacity(ownedTokens.count) + for token in ownedTokens { + guard let entry = v2BrowserElementRefs[token], + entry.navigationGeneration == previousGeneration else { + v2BrowserElementRefs.removeValue(forKey: token) + continue + } + retainedTokens.insert(token) + } + if retainedTokens.isEmpty { + v2BrowserElementRefTokensBySurface.removeValue(forKey: surfaceId) + } else { + v2BrowserElementRefTokensBySurface[surfaceId] = retainedTokens + } + v2BrowserNavigationGenerationBySurface[surfaceId] = previousGeneration + 1 + v2BrowserElementRefBySelectorBySurface.removeValue(forKey: surfaceId) + v2BrowserElementRefBytesBySurface.removeValue(forKey: surfaceId) } - func v2BrowserAllocateElementRef(surfaceId: UUID, selector: String) -> String { - let ref = "@e\(v2BrowserNextElementOrdinal)" - v2BrowserNextElementOrdinal += 1 - v2BrowserElementRefs[ref] = V2BrowserElementRefEntry( - surfaceId: surfaceId, - selector: selector, - navigationGeneration: v2BrowserNavigationGeneration(forSurface: surfaceId) + func v2BrowserAllocateElementRefs( + surfaceId: UUID, + selectors: [String] + ) -> V2BrowserElementRefAllocation { + var selectorIndex = v2BrowserElementRefBySelectorBySurface[surfaceId] ?? [:] + var unseenSelectors: Set = [] + var requestedBytes = 0 + var hasOversizedSelector = false + for selector in selectors where selectorIndex[selector] == nil { + guard unseenSelectors.insert(selector).inserted else { continue } + let byteCount = selector.utf8.count + hasOversizedSelector = hasOversizedSelector + || byteCount > Self.v2BrowserElementRefSelectorByteLimit + let (sum, overflow) = requestedBytes.addingReportingOverflow(byteCount) + requestedBytes = overflow ? Int.max : sum + } + + let currentBytes = v2BrowserElementRefBytesBySurface[surfaceId] ?? 0 + let remaining = max(0, Self.v2BrowserElementRefLimit - selectorIndex.count) + let remainingBytes = max(0, Self.v2BrowserElementRefByteLimit - currentBytes) + let capacity = V2BrowserElementRefCapacity( + limit: Self.v2BrowserElementRefLimit, + requestedUnique: unseenSelectors.count, + remaining: remaining, + selectorByteLimit: Self.v2BrowserElementRefSelectorByteLimit, + byteLimit: Self.v2BrowserElementRefByteLimit, + requestedBytes: requestedBytes, + remainingBytes: remainingBytes ) - return ref + guard !hasOversizedSelector, + unseenSelectors.count <= remaining, + requestedBytes <= remainingBytes else { + return .resourceExhausted(capacity) + } + + let generation = v2BrowserNavigationGeneration(forSurface: surfaceId) + var ownedTokens = v2BrowserElementRefTokensBySurface[surfaceId] ?? [] + var refs: [String] = [] + refs.reserveCapacity(selectors.count) + for selector in selectors { + if let existingRef = selectorIndex[selector] { + refs.append(existingRef) + continue + } + + let ref = "@e\(v2BrowserNextElementOrdinal)" + v2BrowserNextElementOrdinal += 1 + v2BrowserElementRefs[ref] = V2BrowserElementRefEntry( + surfaceId: surfaceId, + selector: selector, + navigationGeneration: generation + ) + selectorIndex[selector] = ref + ownedTokens.insert(ref) + refs.append(ref) + } + v2BrowserElementRefBySelectorBySurface[surfaceId] = selectorIndex + v2BrowserElementRefTokensBySurface[surfaceId] = ownedTokens + v2BrowserElementRefBytesBySurface[surfaceId] = currentBytes + requestedBytes + return .allocated(refs) + } + + func v2BrowserPostProcessSnapshotResult(_ browserResult: [String: Any]) -> V2BrowserSnapshotContent { + let reasonOrder = [ + "entry_limit", "node_limit", "text_inspection_limit", "entry_byte_limit", "selector_byte_limit", + "name_byte_limit", "role_byte_limit", "title_byte_limit", "url_byte_limit" + ] + let browserReasons = Set( + (browserResult["truncation_reasons"] as? [String] ?? []) + .prefix(reasonOrder.count) + .filter(reasonOrder.contains) + ) + var activeReasons = browserReasons + + let boundedTitle = v2BrowserBoundUTF8( + (browserResult["title"] as? String) ?? "", + byteLimit: Self.v2BrowserSnapshotTitleByteLimit + ) + let boundedURL = v2BrowserBoundUTF8( + (browserResult["url"] as? String) ?? "", + byteLimit: Self.v2BrowserSnapshotURLByteLimit + ) + if boundedTitle.truncated { activeReasons.insert("title_byte_limit") } + if boundedURL.truncated { activeReasons.insert("url_byte_limit") } + + let allowedRoles: Set = [ + "application", "article", "button", "cell", "checkbox", "columnheader", + "combobox", "directory", "document", "generic", "grid", "gridcell", "group", + "heading", "link", "list", "listbox", "listitem", "main", "menu", "menubar", + "menuitem", "menuitemcheckbox", "menuitemradio", "navigation", "none", "option", + "presentation", "radio", "region", "row", "rowgroup", "rowheader", "searchbox", + "slider", "spinbutton", "switch", "tab", "table", "tablist", "textbox", "toolbar", + "tree", "treegrid", "treeitem" + ] + var entries: [[String: Any]] = [] + entries.reserveCapacity(Self.v2BrowserSnapshotEntryLimit) + var seenSelectors: Set = [] + var entryBytes = 0 + var swiftSelectorSkipped = 0 + var swiftNameTruncated = 0 + var swiftRoleSkipped = 0 + + func boundedEntryDepth(_ value: Any?) -> Int { + if value is Bool { return 0 } + if let depth = value as? Int { + return min(Self.v2BrowserSnapshotMaxDepth, max(0, depth)) + } + guard let number = value as? NSNumber else { return 0 } + let depth = number.doubleValue + guard depth.isFinite else { + return depth.sign == .plus ? Self.v2BrowserSnapshotMaxDepth : 0 + } + if depth <= 0 { return 0 } + if depth >= Double(Self.v2BrowserSnapshotMaxDepth) { + return Self.v2BrowserSnapshotMaxDepth + } + return Int(depth) + } + + let rawEntries = (browserResult["entries"] as? [[String: Any]]) ?? [] + let rawEntriesTruncated = rawEntries.count > Self.v2BrowserSnapshotRawEntryLimit + for untrustedEntry in rawEntries.prefix(Self.v2BrowserSnapshotRawEntryLimit) { + guard let rawSelector = untrustedEntry["selector"] as? String, !rawSelector.isEmpty else { continue } + let boundedSelector = v2BrowserBoundUTF8( + rawSelector, + byteLimit: Self.v2BrowserElementRefSelectorByteLimit + ) + guard !boundedSelector.truncated else { + swiftSelectorSkipped += 1 + activeReasons.insert("selector_byte_limit") + continue + } + let selector = boundedSelector.value + guard seenSelectors.insert(selector).inserted else { continue } + + let boundedName = v2BrowserBoundUTF8( + (untrustedEntry["name"] as? String) ?? "", + byteLimit: Self.v2BrowserSnapshotNameByteLimit + ) + if boundedName.truncated { + swiftNameTruncated += 1 + activeReasons.insert("name_byte_limit") + } + + let rawRole = (untrustedEntry["role"] as? String) ?? "" + let boundedRawRole = v2BrowserBoundUTF8(rawRole, byteLimit: Self.v2BrowserSnapshotRoleByteLimit) + guard !boundedRawRole.truncated else { + swiftRoleSkipped += 1 + activeReasons.insert("role_byte_limit") + continue + } + let role = boundedRawRole.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !role.isEmpty, + role.utf8.count <= Self.v2BrowserSnapshotRoleByteLimit, + allowedRoles.contains(role) else { + swiftRoleSkipped += 1 + activeReasons.insert("role_byte_limit") + continue + } + + guard entries.count < Self.v2BrowserSnapshotEntryLimit else { + activeReasons.insert("entry_limit") + break + } + let selectorBytes = selector.utf8.count + let nameBytes = boundedName.value.utf8.count + let roleBytes = role.utf8.count + let (selectorAndNameBytes, firstOverflow) = selectorBytes.addingReportingOverflow(nameBytes) + let (candidateBytes, secondOverflow) = selectorAndNameBytes.addingReportingOverflow(roleBytes) + let (nextEntryBytes, totalOverflow) = entryBytes.addingReportingOverflow(candidateBytes) + guard !firstOverflow, !secondOverflow, !totalOverflow, + nextEntryBytes <= Self.v2BrowserSnapshotEntryByteLimit else { + activeReasons.insert("entry_byte_limit") + break + } + + let entry: [String: Any] = [ + "selector": selector, + "name": boundedName.value, + "role": role, + "depth": boundedEntryDepth(untrustedEntry["depth"]) + ] + entries.append(entry) + entryBytes = nextEntryBytes + } + + func browserCounter(_ key: String, reason: String) -> Int { + guard browserReasons.contains(reason) else { return 0 } + let raw = (browserResult[key] as? Int) ?? (browserResult[key] as? NSNumber)?.intValue ?? 0 + return min(Self.v2BrowserSnapshotNodeVisitLimit, max(0, raw)) + } + + let boundedText = v2BrowserBoundCharacters( + (browserResult["text"] as? String) ?? "", + limit: Self.v2BrowserSnapshotTextCharacterLimit + ) + let boundedHTML = v2BrowserBoundCharacters( + (browserResult["html"] as? String) ?? "", + limit: Self.v2BrowserSnapshotHTMLCharacterLimit + ) + let rawVisitedNodes = (browserResult["visited_nodes"] as? Int) + ?? (browserResult["visited_nodes"] as? NSNumber)?.intValue + ?? 0 + let visitedNodes = min(Self.v2BrowserSnapshotNodeVisitLimit, max(0, rawVisitedNodes)) + let rawTextInspectedUnits = (browserResult["text_inspected_units"] as? Int) + ?? (browserResult["text_inspected_units"] as? NSNumber)?.intValue + ?? 0 + let textInspectedUnits = min( + Self.v2BrowserSnapshotTextInspectionLimit, + max(0, rawTextInspectedUnits) + ) + let orderedReasons = reasonOrder.filter(activeReasons.contains) + let textTruncated = ((browserResult["text_truncated"] as? Bool) ?? false) || boundedText.truncated + let htmlTruncated = ((browserResult["html_truncated"] as? Bool) ?? false) || boundedHTML.truncated + var metadata: [String: Any] = [ + "truncated": rawEntriesTruncated || textTruncated || htmlTruncated + || !orderedReasons.isEmpty || ((browserResult["truncated"] as? Bool) ?? false), + "truncation_reasons": orderedReasons, + "raw_entry_limit": Self.v2BrowserSnapshotRawEntryLimit, + "element_limit": Self.v2BrowserSnapshotEntryLimit, + "node_limit": Self.v2BrowserSnapshotNodeVisitLimit, + "visited_nodes": visitedNodes, + "text_inspection_limit": Self.v2BrowserSnapshotTextInspectionLimit, + "text_inspected_units": textInspectedUnits, + "entry_byte_limit": Self.v2BrowserSnapshotEntryByteLimit, + "entry_bytes": entryBytes, + "selector_byte_limit": Self.v2BrowserElementRefSelectorByteLimit, + "selector_skipped_count": browserCounter("selector_skipped_count", reason: "selector_byte_limit") + swiftSelectorSkipped, + "name_byte_limit": Self.v2BrowserSnapshotNameByteLimit, + "name_truncated_count": browserCounter("name_truncated_count", reason: "name_byte_limit") + swiftNameTruncated, + "role_byte_limit": Self.v2BrowserSnapshotRoleByteLimit, + "role_skipped_count": browserCounter("role_skipped_count", reason: "role_byte_limit") + swiftRoleSkipped, + "title_byte_limit": Self.v2BrowserSnapshotTitleByteLimit, + "url_byte_limit": Self.v2BrowserSnapshotURLByteLimit + ] + if textTruncated { + metadata["text_truncated"] = true + } + if htmlTruncated { + metadata["html_truncated"] = true + } + return V2BrowserSnapshotContent( + title: boundedTitle.value, + url: boundedURL.value, + entries: entries, + text: boundedText.value, + html: boundedHTML.value, + metadata: metadata + ) + } + + private func v2BrowserBoundUTF8(_ value: String, byteLimit: Int) -> (value: String, truncated: Bool) { + var bytes = 0 + var boundary = value.startIndex + for character in value { + let characterBytes = String(character).utf8.count + guard bytes <= byteLimit - characterBytes else { + return (String(value[.. (value: String, truncated: Bool) { + guard let boundary = value.index(value.startIndex, offsetBy: limit, limitedBy: value.endIndex), + boundary != value.endIndex else { + return (value, false) + } + return (String(value[.. 0 { + queue.removeFirst(overflow) + v2BrowserDownloadDroppedEventCountBySurface[surfaceId, default: 0] += overflow + } + v2BrowserDownloadEventsBySurface[surfaceId] = queue + } + + @MainActor + func v2BrowserConsumeDownloadEvent(surfaceId: UUID) -> (event: [String: Any], droppedEvents: Int)? { + guard var queue = v2BrowserDownloadEventsBySurface[surfaceId], !queue.isEmpty else { + return nil + } + let event = queue.removeFirst() + if queue.isEmpty { + v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId) + } else { + v2BrowserDownloadEventsBySurface[surfaceId] = queue + } + let droppedEvents = v2BrowserDownloadDroppedEventCountBySurface.removeValue(forKey: surfaceId) ?? 0 + return (event, droppedEvents) + } + + @MainActor + func v2BrowserWaitForDownloadEvent( + surfaceId: UUID, + timeout: TimeInterval + ) -> V2BrowserDownloadEventWaitOutcome { + guard v2BrowserPendingDownloadEventWaiter == nil else { + return .busy + } + if let queued = v2BrowserConsumeDownloadEvent(surfaceId: surfaceId) { + return .event(queued.event, droppedEvents: queued.droppedEvents) + } + + let waiterId = UUID() + let outcome: V2BrowserDownloadEventWaitOutcome? = v2AwaitCallback(timeout: timeout) { finish in + v2BrowserPendingDownloadEventWaiter = V2BrowserDownloadEventWaiter( + surfaceId: surfaceId, + id: waiterId, + finish: finish + ) + } + if let outcome { + return outcome + } + + if v2BrowserPendingDownloadEventWaiter?.id == waiterId { + v2BrowserPendingDownloadEventWaiter = nil + } + return .timedOut + } + + func v2BrowserPermanentlyRemoveSurfaceState(surfaceId: UUID) { + let downloadWaiter: V2BrowserDownloadEventWaiter? + if v2BrowserPendingDownloadEventWaiter?.surfaceId == surfaceId { + downloadWaiter = v2BrowserPendingDownloadEventWaiter + v2BrowserPendingDownloadEventWaiter = nil + } else { + downloadWaiter = nil + } + for token in v2BrowserElementRefTokensBySurface.removeValue(forKey: surfaceId) ?? [] { + v2BrowserElementRefs.removeValue(forKey: token) + } + v2BrowserElementRefBySelectorBySurface.removeValue(forKey: surfaceId) + v2BrowserElementRefBytesBySurface.removeValue(forKey: surfaceId) + v2BrowserNavigationGenerationBySurface.removeValue(forKey: surfaceId) + v2BrowserInitScriptsBySurface.removeValue(forKey: surfaceId) + v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId) + v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId) + v2BrowserDownloadDroppedEventCountBySurface.removeValue(forKey: surfaceId) + v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId) + v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId) + downloadWaiter?.finish(.cancelled) + } + + func v2BrowserElementRefResourceExhaustedResult( + surfaceId: UUID, + capacity: V2BrowserElementRefCapacity + ) -> V2CallResult { + let data: [String: AnyHashable] = [ + "surface_id": surfaceId.uuidString, + "limit": capacity.limit, + "scope": "navigation", + "retry": "navigate or reuse an existing selector", + "requested_unique": capacity.requestedUnique, + "remaining": capacity.remaining, + "selector_byte_limit": capacity.selectorByteLimit, + "byte_limit": capacity.byteLimit, + "requested_bytes": capacity.requestedBytes, + "remaining_bytes": capacity.remainingBytes + ] + return .err( + code: "resource_exhausted", + message: "Browser element reference limit reached for this page", + data: data + ) + } + + private func v2BrowserWithAllocatedElementRef( + surfaceId: UUID, + selector: String, + buildResult: (String) -> V2CallResult + ) -> V2CallResult { + switch v2BrowserAllocateElementRefs(surfaceId: surfaceId, selectors: [selector]) { + case .allocated(let refs): + guard let ref = refs.first else { + return .err(code: "internal_error", message: "Element reference allocation failed", data: nil) + } + return buildResult(ref) + case .resourceExhausted(let capacity): + return v2BrowserElementRefResourceExhaustedResult(surfaceId: surfaceId, capacity: capacity) + } } private enum V2BrowserSelectorLookup { @@ -1150,6 +1698,13 @@ extension TerminalController { let trimmed = rawSelector.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return .notFound } + // "@..." is reserved for element-ref tokens (never a literal CSS selector), so any + // "@"-prefixed input that isn't a well-formed "@e" ref must report notFound + // rather than silently falling through to literal CSS-selector interpretation. + if trimmed.hasPrefix("@"), !trimmed.hasPrefix("@e") { + return .notFound + } + let refKey: String? = { if trimmed.hasPrefix("@e") { return trimmed } if trimmed.hasPrefix("e"), Int(trimmed.dropFirst()) != nil { return "@\(trimmed)" } @@ -1214,25 +1769,33 @@ extension TerminalController { return .applied } - private func v2RunBrowserJavaScript( + private func v2RunBrowserJavaScriptOutcome( _ webView: WKWebView, surfaceId: UUID, script: String, timeout: TimeInterval = 5.0, useEval: Bool = true - ) -> V2JavaScriptResult { + ) -> V2BrowserJavaScriptExecutionOutcome { let scriptLiteral = v2JSONLiteral(script) let framePrelude: String if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) { let selectorLiteral = v2JSONLiteral(frameSelector) framePrelude = """ - let __programaDoc = document; + const __programaFrameSelector = \(selectorLiteral); + let __programaDoc = null; try { - const __programaFrame = document.querySelector(\(selectorLiteral)); - if (__programaFrame && __programaFrame.contentDocument) { - __programaDoc = __programaFrame.contentDocument; + const __programaFrame = document.querySelector(__programaFrameSelector); + const __programaFrameName = __programaFrame?.namespaceURI === 'http://www.w3.org/1999/xhtml' + ? __programaFrame.localName + : null; + if (__programaFrameName !== 'iframe' && __programaFrameName !== 'frame') { + return { __programa_status: 'frame_unavailable' }; } - } catch (_) {} + __programaDoc = __programaFrame.contentDocument; + if (!__programaDoc) return { __programa_status: 'frame_unavailable' }; + } catch (_) { + return { __programa_status: 'frame_unavailable' }; + } """ } else { framePrelude = "const __programaDoc = document;" @@ -1257,9 +1820,11 @@ extension TerminalController { const __programaEvalInFrame = async function() { const document = __programaDoc; + const window = __programaDoc.defaultView; \(executionBlock) const __value = await __programaMaybeAwait(__r); return { + __programa_status: 'completed', __programa_t: (typeof __value === 'undefined') ? 'undefined' : 'value', __programa_v: __value }; @@ -1273,47 +1838,223 @@ extension TerminalController { script: asyncFunctionBody, timeout: timeout, preferAsync: true, - contentWorld: .page + contentWorld: useEval ? .page : .defaultClient ) - if !useEval, case .failure(let pageMessage) = rawResult { - let isolatedResult = v2RunJavaScript( + // Non-eval callers (browser.wait, generated selector actions, and the snapshot + // collector) run in the isolated `.defaultClient` world above, but the page-world + // script environment can still transiently fail right after a fresh navigation (e.g. + // browser.open_split returns before the page-world script context is ready). Retry once + // against `.page` before giving up so a single transient failure doesn't turn into an + // immediate false/timeout instead of the caller's normal polling/retry behavior. + if !useEval, case .failure(let isolatedMessage) = rawResult { + let pageWorldResult = v2RunJavaScript( webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true, - contentWorld: .defaultClient + contentWorld: .page ) - switch isolatedResult { + switch pageWorldResult { case .success: - rawResult = isolatedResult - case .failure(let isolatedMessage): - if isolatedMessage != pageMessage { - rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))") + rawResult = pageWorldResult + case .failure(let pageMessage): + if pageMessage != isolatedMessage { + rawResult = .failure("\(isolatedMessage) (page-world retry: \(pageMessage))") } } } switch rawResult { case .failure(let message): - return .failure(message) + return .failed(message) case .success(let value): guard let dict = value as? [String: Any], + let status = dict["__programa_status"] as? String else { + return .completed(value) + } + if status == "frame_unavailable" { + return .frameUnavailable(v2BrowserCurrentFrameSelector(surfaceId: surfaceId) ?? "") + } + guard status == "completed", let type = dict[Self.v2BrowserEvalEnvelopeTypeKey] as? String else { - return .success(value) + return .failed("Invalid browser JavaScript envelope") } switch type { case Self.v2BrowserEvalEnvelopeTypeUndefined: - return .success(v2BrowserUndefinedSentinel) + return .completed(v2BrowserUndefinedSentinel) case Self.v2BrowserEvalEnvelopeTypeValue: - return .success(dict[Self.v2BrowserEvalEnvelopeValueKey]) + return .completed(dict[Self.v2BrowserEvalEnvelopeValueKey]) + default: + return .failed("Invalid browser JavaScript value type") + } + } + } + + private func v2RunBrowserJavaScript( + _ webView: WKWebView, + surfaceId: UUID, + script: String, + timeout: TimeInterval = 5.0, + useEval: Bool = true + ) -> V2JavaScriptResult { + switch v2RunBrowserJavaScriptOutcome( + webView, + surfaceId: surfaceId, + script: script, + timeout: timeout, + useEval: useEval + ) { + case .completed(let value): + return .success(value) + case .frameUnavailable(let selector): + return .failure("Selected frame unavailable: \(selector)") + case .failed(let message): + return .failure(message) + } + } + + @MainActor + func v2BrowserRunGeneratedSelectorAction( + webView: WKWebView, + surfaceId: UUID, + selector rawSelector: String, + action: V2BrowserGeneratedSelectorAction + ) -> V2BrowserGeneratedSelectorActionOutcome { + guard let selector = v2BrowserResolveSelector(rawSelector, surfaceId: surfaceId) else { + return .elementNotFound + } + let selectorLiteral = v2JSONLiteral(selector) + let script: String + switch action { + case .click: + script = """ + (() => { + const element = document.querySelector(\(selectorLiteral)); + if (!element) return { ok: false, error: 'not_found' }; + element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + if (typeof element.click === 'function') { + element.click(); + } else { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, detail: 1 })); + } + return { ok: true }; + })() + """ + } + + switch v2RunBrowserJavaScriptOutcome( + webView, + surfaceId: surfaceId, + script: script, + useEval: false + ) { + case .frameUnavailable(let selector): + return .frameUnavailable(selector) + case .failed(let message): + return .failed(message) + case .completed(let value): + guard let result = value as? [String: Any] else { + return .failed("Invalid generated selector action result") + } + if result["ok"] as? Bool == true { + return .succeeded + } + if result["error"] as? String == "not_found" { + return .elementNotFound + } + return .failed("Generated selector action failed") + } + } + + @MainActor + func v2BrowserCollectSnapshotJavaScriptOutcome( + webView: WKWebView, + surfaceId: UUID, + script: String + ) -> V2BrowserSnapshotCollectionOutcome { + let framePrelude: String + if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) { + let selectorLiteral = v2JSONLiteral(frameSelector) + framePrelude = """ + const __programaSnapshotFrameSelector = \(selectorLiteral); + let __programaSnapshotDocument = null; + try { + const __programaSnapshotFrame = document.querySelector(__programaSnapshotFrameSelector); + const __programaSnapshotFrameName = __programaSnapshotFrame?.namespaceURI === 'http://www.w3.org/1999/xhtml' + ? __programaSnapshotFrame.localName + : null; + if (__programaSnapshotFrameName !== 'iframe' && __programaSnapshotFrameName !== 'frame') { + return { __programa_snapshot_status: 'frame_unavailable' }; + } + __programaSnapshotDocument = __programaSnapshotFrame.contentDocument; + if (!__programaSnapshotDocument) { + return { __programa_snapshot_status: 'frame_unavailable' }; + } + } catch (_) { + return { __programa_snapshot_status: 'frame_unavailable' }; + } + """ + } else { + framePrelude = "const __programaSnapshotDocument = document;" + } + + let collectorBody = """ + \(framePrelude) + const __programaCollectSnapshot = function() { + const document = __programaSnapshotDocument; + return \(script); + }; + return { + __programa_snapshot_status: 'collected', + __programa_snapshot_value: __programaCollectSnapshot() + }; + """ + switch v2RunJavaScript( + webView, + script: collectorBody, + timeout: 10.0, + preferAsync: true, + contentWorld: .defaultClient + ) { + case .success(let value): + guard let envelope = value as? [String: Any], + let status = envelope["__programa_snapshot_status"] as? String else { + return .failed("Invalid snapshot collector envelope") + } + switch status { + case "collected": + guard let result = envelope["__programa_snapshot_value"] as? [String: Any] else { + return .failed("Invalid snapshot payload") + } + return .collected(result) + case "frame_unavailable": + return .frameUnavailable(v2BrowserCurrentFrameSelector(surfaceId: surfaceId) ?? "") default: - return .success(value) + return .failed("Unknown snapshot collector status") } + case .failure(let message): + return .failed(message) } } + @MainActor + func v2BrowserCollectSnapshotJavaScriptResult( + webView: WKWebView, + surfaceId: UUID, + script: String + ) -> [String: Any]? { + guard case .collected(let result) = v2BrowserCollectSnapshotJavaScriptOutcome( + webView: webView, + surfaceId: surfaceId, + script: script + ) else { + return nil + } + return result + } + func v2BrowserRecordUnsupportedRequest(surfaceId: UUID, request: [String: Any]) { var logs = v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? [] logs.append(request) @@ -1323,13 +2064,14 @@ extension TerminalController { v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] = logs } - private func v2PNGData(from image: NSImage) -> Data? { + @MainActor + private static func v2PNGData(from image: NSImage) -> Data? { guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } return rep.representation(using: .png, properties: [:]) } - private func bestEffortPruneTemporaryFiles( + private nonisolated static func bestEffortPruneTemporaryFiles( in directoryURL: URL, keepingMostRecent maxCount: Int = 50, maxAge: TimeInterval = 24 * 60 * 60 @@ -1583,15 +2325,15 @@ extension TerminalController { return result } - func v2BrowserBack(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserBack(params: [String: Any]) -> V2CallResult { return v2BrowserNavSimple(params: params, action: "back") } - func v2BrowserForward(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserForward(params: [String: Any]) -> V2CallResult { return v2BrowserNavSimple(params: params, action: "forward") } - func v2BrowserReload(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserReload(params: [String: Any]) -> V2CallResult { return v2BrowserNavSimple(params: params, action: "reload") } @@ -1765,10 +2507,10 @@ extension TerminalController { } } - func v2BrowserSelectorAction( + nonisolated func v2BrowserSelectorAction( params: [String: Any], actionName: String, - scriptBuilder: (_ selectorLiteral: String) -> String + scriptBuilder: @MainActor @Sendable (_ selectorLiteral: String) -> String ) -> V2CallResult { guard let selectorRaw = v2BrowserSelector(params) else { return .err(code: "invalid_params", message: "Missing selector", data: nil) @@ -1783,10 +2525,21 @@ extension TerminalController { let selectorCondition = "document.querySelector(\(v2JSONLiteral(selector))) !== null" for attempt in 1...retryAttempts { - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) { - case .failure(let message): + switch v2RunBrowserJavaScriptOutcome( + browserPanel.webView, + surfaceId: surfaceId, + script: script, + useEval: false + ) { + case .frameUnavailable(let frameSelector): + return .err( + code: "not_found", + message: "Selected frame is unavailable", + data: ["action": actionName, "selector": selector, "frame_selector": frameSelector] + ) + case .failed(let message): return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector]) - case .success(let value): + case .completed(let value): if let dict = value as? [String: Any], let ok = dict["ok"] as? Bool, ok { @@ -1848,7 +2601,7 @@ extension TerminalController { } } - func v2BrowserEval(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserEval(params: [String: Any]) -> V2CallResult { guard let script = v2String(params, "script") else { return .err(code: "invalid_params", message: "Missing script", data: nil) } @@ -1868,226 +2621,812 @@ extension TerminalController { } } - func v2BrowserSnapshot(params: [String: Any]) -> V2CallResult { - let interactiveOnly = v2Bool(params, "interactive") ?? false - let includeCursor = v2Bool(params, "cursor") ?? false - let compact = v2Bool(params, "compact") ?? false - let maxDepth = max(0, v2Int(params, "max_depth") ?? v2Int(params, "maxDepth") ?? 12) - let scopeSelector = v2String(params, "selector") + func v2BrowserSnapshotJavaScript( + interactiveOnly: Bool, + includeCursor: Bool, + compact: Bool, + maxDepth: Int, + scopeSelector: String? + ) -> String { + let boundedMaxDepth = min(Self.v2BrowserSnapshotMaxDepth, max(0, maxDepth)) + let interactiveLiteral = interactiveOnly ? "true" : "false" + let cursorLiteral = includeCursor ? "true" : "false" + let compactLiteral = compact ? "true" : "false" + let scopeLiteral = scopeSelector.map(v2JSONLiteral) ?? "null" + return """ + (() => { + const __interactiveOnly = \(interactiveLiteral); + const __includeCursor = \(cursorLiteral); + const __compact = \(compactLiteral); + const __maxDepth = \(boundedMaxDepth); + const __scopeSelector = \(scopeLiteral); + const __nodeLimit = \(Self.v2BrowserSnapshotNodeVisitLimit); + const __entryLimit = \(Self.v2BrowserSnapshotEntryLimit); + const __selectorByteLimit = \(Self.v2BrowserElementRefSelectorByteLimit); + const __nameByteLimit = \(Self.v2BrowserSnapshotNameByteLimit); + const __roleByteLimit = \(Self.v2BrowserSnapshotRoleByteLimit); + const __entryByteLimit = \(Self.v2BrowserSnapshotEntryByteLimit); + const __titleByteLimit = \(Self.v2BrowserSnapshotTitleByteLimit); + const __urlByteLimit = \(Self.v2BrowserSnapshotURLByteLimit); + const __textLimit = \(Self.v2BrowserSnapshotTextCharacterLimit); + const __htmlLimit = \(Self.v2BrowserSnapshotHTMLCharacterLimit); + const __textInspectionLimit = \(Self.v2BrowserSnapshotTextInspectionLimit); + const __attributeLimit = 4096; + const __htmlNamespace = 'http://www.w3.org/1999/xhtml'; + const __reasonOrder = ['entry_limit','node_limit','text_inspection_limit','entry_byte_limit','selector_byte_limit','name_byte_limit','role_byte_limit','title_byte_limit','url_byte_limit']; + const __reasons = new Set(); + const __interactiveRoles = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']); + const __contentRoles = new Set(['heading','cell','gridcell','columnheader','rowheader','listitem','article','region','main','navigation']); + const __allowedRoles = new Set([...__interactiveRoles, ...__contentRoles, 'application','directory','document','generic','grid','group','list','menu','menubar','none','presentation','row','rowgroup','table','tablist','toolbar','tree','treegrid']); + const __voidTags = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']); + const __rawTextTags = new Set(['script','style','xmp','iframe','noembed','noframes','plaintext']); + const __nonContentTextTags = new Set(['script','style','noscript','template']); + + const __byteWidth = (codePoint) => codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + const __boundedUTF8 = (input, limit, normalizeWhitespace = false) => { + const source = String(input == null ? '' : input); + let value = ''; + let bytes = 0; + let truncated = false; + let pendingSpace = false; + let inspected = 0; + for (const character of source) { + inspected += 1; + if (normalizeWhitespace && inspected > (limit * 4 + 256)) { + truncated = true; + break; + } + if (normalizeWhitespace && /\\s/u.test(character)) { + if (value) pendingSpace = true; + continue; + } + const width = __byteWidth(character.codePointAt(0)); + const spaceWidth = pendingSpace ? 1 : 0; + if (bytes + spaceWidth + width > limit) { + truncated = true; + break; + } + if (pendingSpace) { + value += ' '; + bytes += 1; + pendingSpace = false; + } + value += character; + bytes += width; + } + return { value, bytes, truncated }; + }; + const __htmlLocalName = (element, byteLimit = 64) => { + if (!element || element.namespaceURI !== __htmlNamespace) return null; + const bounded = __boundedUTF8(element.localName || '', byteLimit); + if (!bounded.value || bounded.truncated) return null; + return bounded.value.toLowerCase(); + }; - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - let interactiveLiteral = interactiveOnly ? "true" : "false" - let cursorLiteral = includeCursor ? "true" : "false" - let compactLiteral = compact ? "true" : "false" - let scopeLiteral = scopeSelector.map(v2JSONLiteral) ?? "null" + const __title = __boundedUTF8(document.title || '', __titleByteLimit); + const __url = __boundedUTF8(document.location?.href || '', __urlByteLimit); + if (__title.truncated) __reasons.add('title_byte_limit'); + if (__url.truncated) __reasons.add('url_byte_limit'); - let script = """ - (() => { - const __interactiveOnly = \(interactiveLiteral); - const __includeCursor = \(cursorLiteral); - const __compact = \(compactLiteral); - const __maxDepth = \(maxDepth); - const __scopeSelector = \(scopeLiteral); - - const __normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim(); - const __interactiveRoles = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']); - const __contentRoles = new Set(['heading','cell','gridcell','columnheader','rowheader','listitem','article','region','main','navigation']); - const __structuralRoles = new Set(['generic','group','list','table','row','rowgroup','grid','treegrid','menu','menubar','toolbar','tablist','tree','directory','document','application','presentation','none']); - - const __isVisible = (el) => { - try { - if (!el) return false; - const style = getComputedStyle(el); - const rect = el.getBoundingClientRect(); - if (!style || !rect) return false; - if (rect.width <= 0 || rect.height <= 0) return false; - if (style.display === 'none' || style.visibility === 'hidden') return false; - if (parseFloat(style.opacity || '1') <= 0.01) return false; - return true; - } catch (_) { - return false; + let __root = document.body || document.documentElement; + let __scoped = false; + if (__scopeSelector) { + try { + const boundedScope = __boundedUTF8(__scopeSelector, __selectorByteLimit); + if (!boundedScope.truncated && boundedScope.value) { + const scopedRoot = document.querySelector(boundedScope.value); + if (scopedRoot) { + __root = scopedRoot; + __scoped = true; } - }; + } + } catch (_) {} + } + const __serializationRoot = __scoped ? __root : (document.documentElement || __root); + const __entries = []; + const __seenSelectors = new Set(); + const __pathByElement = new WeakMap(); + const __elementChildrenSeenByParent = new WeakMap(); + let __entryBytes = 0; + let __visitedNodes = 0; + let __workNodes = 0; + let __nodeBudgetExhausted = false; + let __selectorSkippedCount = 0; + let __nameTruncatedCount = 0; + let __roleSkippedCount = 0; + let __stop = false; + let __scopeDepth = __scoped ? 0 : null; + let __scopeActive = __scoped; + + const __chargeNodeWork = () => { + if (__workNodes >= __nodeLimit) { + __nodeBudgetExhausted = true; + __reasons.add('node_limit'); + return false; + } + __workNodes += 1; + return true; + }; - const __implicitRole = (el) => { - const tag = String(el.tagName || '').toLowerCase(); - if (tag === 'button') return 'button'; - if (tag === 'a' && el.hasAttribute('href')) return 'link'; - if (tag === 'input') { - const type = String(el.getAttribute('type') || 'text').toLowerCase(); - if (type === 'checkbox') return 'checkbox'; - if (type === 'radio') return 'radio'; - if (type === 'submit' || type === 'button' || type === 'reset') return 'button'; - return 'textbox'; - } - if (tag === 'textarea') return 'textbox'; - if (tag === 'select') return 'combobox'; - if (tag === 'summary') return 'button'; - if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') return 'heading'; - if (tag === 'li') return 'listitem'; - return null; - }; + let __text = ''; + let __textTruncated = false; + let __textInspectedUnits = 0; + let __textInspectionExhausted = false; + let __textAuthoredWhitespace = false; + let __textSeparatorRequested = false; + let __textOutputStopped = false; + const __markTextInspectionExhausted = () => { + __textInspectionExhausted = true; + __textTruncated = true; + __reasons.add('text_inspection_limit'); + }; + const __inspectTextSource = (value, consume) => { + if (__textInspectionExhausted || value == null) return { truncated: __textInspectionExhausted, stopped: false }; + const source = String(value); + let index = 0; + while (index < source.length) { + const codePoint = source.codePointAt(index); + const character = String.fromCodePoint(codePoint); + const units = character.length; + if (__textInspectedUnits > __textInspectionLimit - units) { + __markTextInspectionExhausted(); + return { truncated: true, stopped: false }; + } + __textInspectedUnits += units; + index += units; + if (consume(character) === false) return { truncated: false, stopped: true }; + } + return { truncated: false, stopped: false }; + }; + const __requestTextSeparator = () => { + if (__text) __textSeparatorRequested = true; + }; + const __appendText = (value) => { + if (__textOutputStopped || __textInspectionExhausted || !value) return; + __inspectTextSource(value, (character) => { + if (/\\s/u.test(character)) { + if (__text) __textAuthoredWhitespace = true; + return true; + } + const needsSpace = __text && (__textAuthoredWhitespace || __textSeparatorRequested); + const required = character.length + (needsSpace ? 1 : 0); + if (__text.length > __textLimit - required) { + __textTruncated = true; + __textOutputStopped = true; + return false; + } + if (needsSpace) __text += ' '; + __text += character; + __textAuthoredWhitespace = false; + __textSeparatorRequested = false; + return true; + }); + }; - const __nameFor = (el) => { - const aria = __normalize(el.getAttribute('aria-label') || ''); - if (aria) return aria; - const labelledBy = __normalize(el.getAttribute('aria-labelledby') || ''); - if (labelledBy) { - const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((n) => __normalize(n.textContent || '')).join(' ').trim(); - if (text) return text; - } - if (el.tagName && String(el.tagName).toLowerCase() === 'input') { - const placeholder = __normalize(el.getAttribute('placeholder') || ''); - if (placeholder) return placeholder; - const value = __normalize(el.value || ''); - if (value) return value; - } - const title = __normalize(el.getAttribute('title') || ''); - if (title) return title; - const text = __normalize(el.innerText || el.textContent || ''); - if (text) return text.slice(0, 120); - return ''; - }; + let __html = ''; + let __htmlTruncated = false; + let __htmlStopped = false; + let __attributeCount = 0; + const __appendHTML = (value) => { + if (__htmlStopped || !value) return; + const remaining = __htmlLimit - __html.length; + if (remaining <= 0) { __htmlTruncated = true; __htmlStopped = true; return; } + const source = String(value); + __html += source.slice(0, remaining); + if (source.length > remaining) { __htmlTruncated = true; __htmlStopped = true; } + }; + const __appendEscapedHTML = (value, attribute) => { + if (__htmlStopped) return; + const source = String(value == null ? '' : value); + const remaining = Math.max(0, __htmlLimit - __html.length); + const probe = source.slice(0, remaining + 1); + const needsEscaping = attribute ? /[&<>\"]/u.test(probe) : /[&<>]/u.test(probe); + if (!needsEscaping) { + __appendHTML(source); + return; + } + for (const character of source) { + let escaped = character; + if (character === '&') escaped = '&'; + else if (character === '<') escaped = '<'; + else if (character === '>') escaped = '>'; + else if (attribute && character === '"') escaped = '"'; + __appendHTML(escaped); + if (__htmlStopped) return; + } + }; + const __appendBoundedHTMLName = (rawName, lowercase) => { + if (__htmlStopped) return; + const remaining = __htmlLimit - __html.length; + if (remaining <= 0) { __htmlTruncated = true; __htmlStopped = true; return; } + const source = String(rawName || ''); + const boundedSource = source.slice(0, remaining + 1); + __appendHTML(lowercase ? boundedSource.toLowerCase() : boundedSource); + if (!__htmlStopped && source.length > boundedSource.length) { + __htmlTruncated = true; + __htmlStopped = true; + } + }; + const __descriptorByElement = new WeakMap(); + const __elementDescriptor = (element) => { + const cached = __descriptorByElement.get(element); + if (cached) return cached; + const isHTML = element.namespaceURI === __htmlNamespace; + const local = __boundedUTF8(element.localName || '', 64); + const descriptor = { + isHTML, + semanticLocal: isHTML && !local.truncated ? local.value.toLowerCase() : null, + prefix: element.prefix || '', + localName: element.localName || '' + }; + __descriptorByElement.set(element, descriptor); + return descriptor; + }; + const __appendElementName = (element) => { + const descriptor = __elementDescriptor(element); + if (descriptor.prefix) { + __appendBoundedHTMLName(descriptor.prefix, false); + __appendHTML(':'); + } + __appendBoundedHTMLName(descriptor.localName, descriptor.isHTML); + }; + const __appendAttributeName = (attribute) => { + if (attribute.prefix) { + __appendBoundedHTMLName(attribute.prefix, false); + __appendHTML(':'); + __appendBoundedHTMLName(attribute.localName, false); + } else { + __appendBoundedHTMLName(attribute.name || attribute.localName, false); + } + }; + const __appendOpenTag = (element) => { + if (__htmlStopped) return; + __appendHTML('<'); + __appendElementName(element); + if (__htmlStopped) return; + const attributes = element.attributes; + for (let index = 0; index < attributes.length; index += 1) { + if (__attributeCount >= __attributeLimit) { + __htmlTruncated = true; + __htmlStopped = true; + return; + } + __attributeCount += 1; + const attribute = attributes.item(index); + if (!attribute) continue; + __appendHTML(' '); + __appendAttributeName(attribute); + __appendHTML('='); + __appendHTML('\"'); + __appendEscapedHTML(attribute.value, true); + __appendHTML('\"'); + if (__htmlStopped) return; + } + __appendHTML('>'); + }; + const __appendCloseTag = (element) => { + if (__htmlStopped) return; + const descriptor = __elementDescriptor(element); + if (descriptor.isHTML && descriptor.semanticLocal && __voidTags.has(descriptor.semanticLocal)) return; + __appendHTML('<'); + __appendHTML('/'); + __appendElementName(element); + if (!__htmlStopped) __appendHTML('>'); + }; - const __cssPath = (el) => { - if (!el || el.nodeType !== 1) return null; - if (el.id) return '#' + CSS.escape(el.id); - const parts = []; - let cur = el; - while (cur && cur.nodeType === 1) { - let part = String(cur.tagName || '').toLowerCase(); - if (!part) break; - if (cur.id) { - part += '#' + CSS.escape(cur.id); - parts.unshift(part); - break; - } - const tag = part; - const parent = cur.parentElement; - if (parent) { - const siblings = Array.from(parent.children).filter((n) => String(n.tagName || '').toLowerCase() === tag); - if (siblings.length > 1) { - const index = siblings.indexOf(cur) + 1; - part += `:nth-of-type(${index})`; - } - } - parts.unshift(part); - cur = cur.parentElement; - if (parts.length >= 6) break; - } - return parts.join(' > '); - }; + const __implicitRole = (element) => { + const tag = __htmlLocalName(element, 64); + if (!tag) return null; + if (tag === 'button' || tag === 'summary') return 'button'; + if (tag === 'a' && element.hasAttribute('href')) return 'link'; + if (tag === 'input') { + const type = __boundedUTF8(element.getAttribute('type') || 'text', 32, true).value.toLowerCase(); + if (type === 'checkbox') return 'checkbox'; + if (type === 'radio') return 'radio'; + if (type === 'submit' || type === 'button' || type === 'reset') return 'button'; + return 'textbox'; + } + if (tag === 'textarea') return 'textbox'; + if (tag === 'select') return 'combobox'; + if (/^h[1-6]$/.test(tag)) return 'heading'; + if (tag === 'li') return 'listitem'; + return null; + }; + const __styleByElement = new WeakMap(); + const __computedStyleFor = (element) => { + if (__styleByElement.has(element)) return __styleByElement.get(element); + try { + const view = element.ownerDocument?.defaultView; + const style = view?.getComputedStyle ? view.getComputedStyle(element) : null; + __styleByElement.set(element, style); + return style; + } catch (_) { + __styleByElement.set(element, null); + return null; + } + }; + const __isVisible = (element) => { + try { + const style = __computedStyleFor(element); + const rect = element.getBoundingClientRect(); + return !!style && !!rect && rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity || '1') > 0.01; + } catch (_) { return false; } + }; + const __cursorEligible = (element) => { + if (!__includeCursor) return false; + try { + const style = __computedStyleFor(element); + const tabIndex = element.getAttribute('tabindex'); + return typeof element.onclick === 'function' || element.hasAttribute('onclick') || style?.cursor === 'pointer' || (tabIndex != null && String(tabIndex) !== '-1'); + } catch (_) { return false; } + }; + const __isRenderedBlock = (element) => { + const tag = __htmlLocalName(element, 64); + if (tag === 'br') return true; + const display = __computedStyleFor(element)?.display || ''; + return !!display && display !== 'none' && display !== 'contents' && !display.startsWith('inline'); + }; + const __isOwnTextSuppressed = (element, ignoreHidden = false) => { + const tag = __htmlLocalName(element, 64); + if (tag && __nonContentTextTags.has(tag)) return true; + if (ignoreHidden) return false; + if (element.hidden || element.hasAttribute('hidden')) return true; + const ariaHidden = __boundedUTF8(element.getAttribute('aria-hidden') || '', 16, true).value.toLowerCase(); + if (ariaHidden === 'true') return true; + const style = __computedStyleFor(element); + return style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse'; + }; + const __templateHostByContent = new WeakMap(); + const __textSuppressedByElement = new WeakMap(); + const __updateTextSuppression = (element) => { + const domParent = element.parentNode; + const logicalParent = __templateHostByContent.get(domParent) || domParent; + const parentSuppressed = logicalParent ? (__textSuppressedByElement.get(logicalParent) || false) : false; + const suppressed = parentSuppressed || __isOwnTextSuppressed(element); + __textSuppressedByElement.set(element, suppressed); + return suppressed; + }; + + const __createNameSink = () => ({ + value: '', bytes: 0, pendingWhitespace: false, separatorRequested: false, + truncated: false, stopped: false + }); + const __appendNameCharacter = (sink, character) => { + if (sink.stopped) return false; + if (/\\s/u.test(character)) { + if (sink.value) sink.pendingWhitespace = true; + return true; + } + const needsSpace = sink.value && (sink.pendingWhitespace || sink.separatorRequested); + const width = __byteWidth(character.codePointAt(0)); + const required = width + (needsSpace ? 1 : 0); + if (sink.bytes > __nameByteLimit - required) { + sink.truncated = true; + sink.stopped = true; + return false; + } + if (needsSpace) { sink.value += ' '; sink.bytes += 1; } + sink.value += character; + sink.bytes += width; + sink.pendingWhitespace = false; + sink.separatorRequested = false; + return true; + }; + const __appendNameSource = (sink, value) => { + if (sink.stopped || !value) return; + const inspected = __inspectTextSource(value, (character) => __appendNameCharacter(sink, character)); + if (inspected.truncated) return; + }; + const __mergeNameValue = (sink, result) => { + if (!result.value) { + if (result.truncated) sink.truncated = true; + return; + } + if (sink.value) sink.separatorRequested = true; + for (const character of result.value) { + if (!__appendNameCharacter(sink, character)) break; + } + if (result.truncated) sink.truncated = true; + }; + const __nameContentCache = new WeakMap(); + const __explicitLabelContentCache = new WeakMap(); + const __walkNameContent = (root, includeHiddenSubtree) => { + const cache = includeHiddenSubtree ? __explicitLabelContentCache : __nameContentCache; + const cached = cache.get(root); + if (cached) return cached; + const sink = __createNameSink(); + const suppressedByElement = new WeakMap(); + let node = root; + while (node) { + if (!__chargeNodeWork()) break; + let suppressed = false; + if (node.nodeType === Node.ELEMENT_NODE) { + const parentSuppressed = node === root ? false : (suppressedByElement.get(node.parentElement) || false); + suppressed = parentSuppressed || __isOwnTextSuppressed(node, includeHiddenSubtree); + suppressedByElement.set(node, suppressed); + if (!suppressed && __isRenderedBlock(node)) sink.separatorRequested = !!sink.value; + } else if (node.nodeType === Node.TEXT_NODE) { + suppressed = suppressedByElement.get(node.parentElement) || false; + if (!suppressed) __appendNameSource(sink, node.nodeValue || ''); + } - const __root = (() => { - if (__scopeSelector) { - return document.querySelector(__scopeSelector) || document.body || document.documentElement; + const descend = node.nodeType === Node.ELEMENT_NODE && !suppressed && !!node.firstChild; + if (descend) { + node = node.firstChild; + continue; + } + while (node) { + if (node.nodeType === Node.ELEMENT_NODE + && !(suppressedByElement.get(node) || false) + && __isRenderedBlock(node) + && sink.value) { + sink.separatorRequested = true; } - return document.body || document.documentElement; - })(); + if (node === root) { node = null; break; } + if (node.nextSibling) { node = node.nextSibling; break; } + node = node.parentNode; + } + } + const result = { value: sink.value, bytes: sink.bytes, truncated: sink.truncated }; + cache.set(root, result); + return result; + }; + const __boundedNameSource = (value) => __boundedUTF8(value || '', __nameByteLimit, true); + const __nameFor = (element) => { + let result = null; + let discoveryTruncated = false; + const labelledBy = __boundedUTF8(element.getAttribute('aria-labelledby') || '', 256, true); + if (labelledBy.value) { + const combined = __createNameSink(); + const resolvedLabels = new Set(); + let count = 0; + for (const id of labelledBy.value.split(' ')) { + if (!id) continue; + if (count >= 16) { combined.truncated = true; break; } + count += 1; + const labelled = element.ownerDocument?.getElementById(id); + if (!labelled || resolvedLabels.has(labelled)) continue; + resolvedLabels.add(labelled); + __mergeNameValue(combined, __walkNameContent(labelled, true)); + if (combined.stopped || __nodeBudgetExhausted) break; + } + discoveryTruncated = labelledBy.truncated || combined.truncated; + if (combined.value) result = { value: combined.value, bytes: combined.bytes, truncated: combined.truncated }; + } else { + discoveryTruncated = labelledBy.truncated; + } + if (!result) { + const ariaLabel = __boundedNameSource(element.getAttribute('aria-label') || ''); + if (ariaLabel.value) result = ariaLabel; + discoveryTruncated = discoveryTruncated || ariaLabel.truncated; + } + const tag = __htmlLocalName(element, 64); + if (!result && (tag === 'input' || tag === 'textarea')) { + const hostName = __boundedNameSource(element.getAttribute('placeholder') || element.value || ''); + if (hostName.value) result = hostName; + discoveryTruncated = discoveryTruncated || hostName.truncated; + } + if (!result) { + const titleName = __boundedNameSource(element.getAttribute('title') || ''); + if (titleName.value) result = titleName; + discoveryTruncated = discoveryTruncated || titleName.truncated; + } + if (!result) result = __walkNameContent(element, false); + if (result.truncated || discoveryTruncated) { + __nameTruncatedCount += 1; + __reasons.add('name_byte_limit'); + } + return result; + }; - const __entries = []; - const __seen = new Set(); - const __appendEntry = (el, depth, forcedRole) => { - if (!__isVisible(el)) return; - const explicitRole = __normalize(el.getAttribute('role') || '').toLowerCase(); - const role = forcedRole || explicitRole || __implicitRole(el) || ''; - if (!role) return; - - if (__interactiveOnly && !__interactiveRoles.has(role)) return; - if (!__interactiveOnly) { - const includeRole = __interactiveRoles.has(role) || __contentRoles.has(role); - if (!includeRole) return; - if (__compact && __structuralRoles.has(role)) { - const name = __nameFor(el); - if (!name) return; - } + const __buildScopedRootPath = (element) => { + const documentElement = element.ownerDocument?.documentElement; + let current = element; + let suffix = ''; + while (current) { + if (!__chargeNodeWork()) return null; + if (current === documentElement) { + const complete = __boundedUTF8(':root' + suffix, __selectorByteLimit); + return complete.truncated ? null : complete.value; + } + const parent = current.parentElement; + if (!parent) return null; + let ordinal = 1; + let sibling = current.previousElementSibling; + while (sibling) { + if (!__chargeNodeWork()) return null; + ordinal += 1; + sibling = sibling.previousElementSibling; + } + const candidate = ' > :nth-child(' + ordinal + ')' + suffix; + if (__boundedUTF8(candidate, __selectorByteLimit).truncated) return null; + suffix = candidate; + current = parent; + } + return null; + }; + let __scopedPathAvailable = !__scoped; + if (__scoped) { + const scopedPath = __buildScopedRootPath(__root); + if (scopedPath) { + __pathByElement.set(__root, scopedPath); + __scopedPathAvailable = true; + } + } + const __recordStructuralPath = (element) => { + const existing = __pathByElement.get(element); + if (existing) return existing; + if (__scoped && element === __root && !__scopedPathAvailable) return null; + if (element === element.ownerDocument?.documentElement) { + __pathByElement.set(element, ':root'); + return ':root'; + } + const parent = element.parentElement; + if (!parent) return null; + const ordinal = (__elementChildrenSeenByParent.get(parent) || 0) + 1; + __elementChildrenSeenByParent.set(parent, ordinal); + const parentPath = __pathByElement.get(parent); + if (!parentPath) return null; + const candidate = parentPath + ' > :nth-child(' + ordinal + ')'; + const bounded = __boundedUTF8(candidate, __selectorByteLimit); + if (bounded.truncated) return null; + __pathByElement.set(element, bounded.value); + return bounded.value; + }; + const __selectorFor = (element) => { + const structural = __pathByElement.get(element) || null; + const rawId = element.id || ''; + if (rawId) { + const rawBound = __boundedUTF8(rawId, __selectorByteLimit); + if (rawBound.truncated) return { selector: null, oversized: true }; + try { + const escapedValue = __boundedUTF8(CSS.escape(rawBound.value), __selectorByteLimit - 1); + if (escapedValue.truncated) return { selector: null, oversized: true }; + const escaped = '#' + escapedValue.value; + if (element.ownerDocument?.querySelector(escaped) === element) { + return { selector: escaped, oversized: false }; } + } catch (_) {} + } + return { selector: structural, oversized: !structural }; + }; - const selector = __cssPath(el); - if (!selector || __seen.has(selector)) return; - __seen.add(selector); - __entries.push({ - selector, - role, - name: __nameFor(el), - depth - }); - }; + const __appendEntry = (element, depth) => { + if (!__isVisible(element)) return; + const cursorEligible = __cursorEligible(element); + const explicitRaw = element.getAttribute('role') || ''; + const explicitBounded = __boundedUTF8(explicitRaw, __roleByteLimit, true); + const explicitValue = explicitBounded.value.toLowerCase(); + const explicitRole = !explicitBounded.truncated && __allowedRoles.has(explicitValue) ? explicitValue : null; + const implicitRole = __implicitRole(element); + let role = explicitRole || implicitRole || (cursorEligible ? 'generic' : null); + if (!role) { + if (explicitRaw) { __roleSkippedCount += 1; __reasons.add('role_byte_limit'); } + return; + } + if (__interactiveOnly && !__interactiveRoles.has(role) && !cursorEligible) return; + if (!__interactiveOnly && !__interactiveRoles.has(role) && !__contentRoles.has(role) && !cursorEligible) return; + const selectorResult = __selectorFor(element); + if (!selectorResult.selector) { + if (selectorResult.oversized) { __selectorSkippedCount += 1; __reasons.add('selector_byte_limit'); } + return; + } + const name = __nameFor(element); + if (__compact && role === 'generic' && !name.value) return; + const selector = selectorResult.selector; + if (__seenSelectors.has(selector)) return; + if (__entries.length >= __entryLimit) { __reasons.add('entry_limit'); __stop = true; return; } + const candidateBytes = __boundedUTF8(selector, __selectorByteLimit).bytes + name.bytes + __boundedUTF8(role, __roleByteLimit).bytes; + if (candidateBytes > __entryByteLimit - __entryBytes) { __reasons.add('entry_byte_limit'); __stop = true; return; } + __seenSelectors.add(selector); + __entryBytes += candidateBytes; + __entries.push({ selector, role, name: name.value, depth }); + }; - const __walk = (node, depth) => { - if (!node || depth > __maxDepth || node.nodeType !== 1) return; - const el = node; - __appendEntry(el, depth, null); - for (const child of Array.from(el.children || [])) { - __walk(child, depth + 1); + const __firstTraversalChild = (node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const descriptor = __elementDescriptor(node); + if (descriptor.isHTML && descriptor.semanticLocal === 'template') { + const content = node.content; + if (content) { + __templateHostByContent.set(content, node); + __textSuppressedByElement.set( + content, + __textSuppressedByElement.get(node) || false + ); + return content.firstChild; } - }; + } + } + return node.firstChild; + }; + const __traversalParent = (node) => { + const domParent = node.parentNode; + return __templateHostByContent.get(domParent) || domParent; + }; - if (__root) { - __walk(__root, 0); + let __node = __serializationRoot; + let __depth = 0; + while (__node && !__stop) { + if (!__chargeNodeWork()) { + __textTruncated = true; + __htmlTruncated = true; + break; + } + __visitedNodes += 1; + const isElement = __node.nodeType === Node.ELEMENT_NODE; + if (__node === __root) { + __scopeDepth = __depth; + __scopeActive = true; + } + const inScope = __scopeActive; + const relativeDepth = __scopeDepth == null ? 0 : __depth - __scopeDepth; + let textSuppressed = false; + if (isElement) { + textSuppressed = __updateTextSuppression(__node); + __appendOpenTag(__node); + __recordStructuralPath(__node); + } + else if (__node.nodeType === Node.TEXT_NODE) { + const parentDescriptor = __node.parentElement ? __elementDescriptor(__node.parentElement) : null; + if (parentDescriptor?.isHTML + && parentDescriptor.semanticLocal + && __rawTextTags.has(parentDescriptor.semanticLocal)) { + __appendHTML(__node.nodeValue || ''); } + else __appendEscapedHTML(__node.nodeValue || '', false); + } else if (__node.nodeType === Node.COMMENT_NODE) { + __appendHTML(''); + } - if (__includeCursor && __root) { - const all = Array.from(__root.querySelectorAll('*')); - for (const el of all) { - if (!__isVisible(el)) continue; - const style = getComputedStyle(el); - const hasOnClick = typeof el.onclick === 'function' || el.hasAttribute('onclick'); - const hasCursorPointer = style.cursor === 'pointer'; - const tabIndex = el.getAttribute('tabindex'); - const hasTabIndex = tabIndex != null && String(tabIndex) !== '-1'; - if (!hasOnClick && !hasCursorPointer && !hasTabIndex) continue; - __appendEntry(el, 0, 'generic'); - if (__entries.length >= 256) break; + if (inScope) { + if (isElement && !textSuppressed) { + if (__isRenderedBlock(__node)) __requestTextSeparator(); + } + if (__node.nodeType === Node.TEXT_NODE) { + const domParent = __node.parentNode; + const parent = __templateHostByContent.get(domParent) || domParent; + if (!parent || !(__textSuppressedByElement.get(parent) || false)) { + __appendText(__node.nodeValue || ''); } } + if (isElement && relativeDepth <= __maxDepth) __appendEntry(__node, relativeDepth); + if (__stop || __nodeBudgetExhausted) { + __textTruncated = true; + __htmlTruncated = true; + break; + } + } - const body = document.body; - const root = document.documentElement; - return { - title: __normalize(document.title || ''), - url: String(location.href || ''), - ready_state: String(document.readyState || ''), - text: body ? String(body.innerText || '') : '', - html: root ? String(root.outerHTML || '') : '', - entries: __entries - }; - })() - """ - - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) { - case .failure(let message): - return .err(code: "js_error", message: message, data: nil) - case .success(let value): - guard let dict = value as? [String: Any] else { - return .err(code: "js_error", message: "Invalid snapshot payload", data: nil) + const firstChild = __firstTraversalChild(__node); + let descend = !!firstChild; + if (inScope && relativeDepth >= __maxDepth && descend) { + descend = false; + __textTruncated = true; + __htmlTruncated = true; + __htmlStopped = true; + } + if (descend) { + __node = firstChild; + __depth += 1; + continue; + } + while (__node) { + if (__node.nodeType === Node.ELEMENT_NODE) { + const closingSuppressed = __textSuppressedByElement.get(__node) || false; + if (__scopeActive && !closingSuppressed && __isRenderedBlock(__node)) { + __requestTextSeparator(); } + __appendCloseTag(__node); + if (__node === __root) __scopeActive = false; + } + if (__node === __serializationRoot) { __node = null; break; } + if (__node.nextSibling) { __node = __node.nextSibling; break; } + __node = __traversalParent(__node); + __depth -= 1; + } + } + + const __truncationReasons = __reasonOrder.filter((reason) => __reasons.has(reason)); + return { + title: __title.value, + url: __url.value, + ready_state: String(document.readyState || ''), + text: __text, + html: __html, + entries: __entries, + truncated: __truncationReasons.length > 0 || __textTruncated || __htmlTruncated, + truncation_reasons: __truncationReasons, + element_limit: __entryLimit, + node_limit: __nodeLimit, + visited_nodes: __visitedNodes, + text_inspection_limit: __textInspectionLimit, + text_inspected_units: __textInspectedUnits, + entry_byte_limit: __entryByteLimit, + entry_bytes: __entryBytes, + selector_byte_limit: __selectorByteLimit, + selector_skipped_count: __selectorSkippedCount, + name_byte_limit: __nameByteLimit, + name_truncated_count: __nameTruncatedCount, + role_byte_limit: __roleByteLimit, + role_skipped_count: __roleSkippedCount, + title_byte_limit: __titleByteLimit, + url_byte_limit: __urlByteLimit, + text_truncated: __textTruncated, + html_truncated: __htmlTruncated + }; + })() + """ + } + + func v2BrowserSnapshot(params: [String: Any]) -> V2CallResult { + let interactiveOnly = v2Bool(params, "interactive") ?? false + let includeCursor = v2Bool(params, "cursor") ?? false + let compact = v2Bool(params, "compact") ?? false + let maxDepth = min(64, max(0, v2Int(params, "max_depth") ?? v2Int(params, "maxDepth") ?? 12)) + let scopeSelector = v2String(params, "selector") + + return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in + let script = v2BrowserSnapshotJavaScript( + interactiveOnly: interactiveOnly, + includeCursor: includeCursor, + compact: compact, + maxDepth: maxDepth, + scopeSelector: scopeSelector + ) + + let dict: [String: Any] + switch v2BrowserCollectSnapshotJavaScriptOutcome( + webView: browserPanel.webView, + surfaceId: surfaceId, + script: script + ) { + case .collected(let value): + dict = value + case .frameUnavailable(let selector): + return .err( + code: "not_found", + message: "Selected browser frame is unavailable", + data: [ + "surface_id": surfaceId.uuidString, + "frame_selector": selector + ] + ) + case .failed(let message): + return .err( + code: "js_error", + message: "Browser snapshot collection failed", + data: ["details": message] + ) + } - let title = (dict["title"] as? String) ?? "" - let url = (dict["url"] as? String) ?? "" let readyState = (dict["ready_state"] as? String) ?? "" - let text = (dict["text"] as? String) ?? "" - let html = (dict["html"] as? String) ?? "" - let entries = (dict["entries"] as? [[String: Any]]) ?? [] + let snapshotContent = v2BrowserPostProcessSnapshotResult(dict) + let title = snapshotContent.title + let url = snapshotContent.url + let text = snapshotContent.text + let html = snapshotContent.html + let boundedEntries = snapshotContent.entries var refs: [String: [String: Any]] = [:] var treeLines: [String] = [] - var seenSelectors: Set = [] - - for entry in entries { - guard let selector = entry["selector"] as? String, - !selector.isEmpty, - !seenSelectors.contains(selector) else { - continue - } - seenSelectors.insert(selector) + let selectors = boundedEntries.compactMap { $0["selector"] as? String } + let allocatedRefs: [String] + switch v2BrowserAllocateElementRefs(surfaceId: surfaceId, selectors: selectors) { + case .allocated(let values): + allocatedRefs = values + case .resourceExhausted(let capacity): + return v2BrowserElementRefResourceExhaustedResult(surfaceId: surfaceId, capacity: capacity) + } + for (entry, refToken) in zip(boundedEntries, allocatedRefs) { let roleRaw = (entry["role"] as? String) ?? "generic" let role = roleRaw.isEmpty ? "generic" : roleRaw let name = ((entry["name"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let depth = max(0, (entry["depth"] as? Int) ?? ((entry["depth"] as? NSNumber)?.intValue ?? 0)) - let refToken = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector) let shortRef = refToken.hasPrefix("@") ? String(refToken.dropFirst()) : refToken var refInfo: [String: Any] = ["role": role] @@ -2144,8 +3483,10 @@ extension TerminalController { if !refs.isEmpty { payload["refs"] = refs } + for (key, value) in snapshotContent.metadata { + payload[key] = value + } return .ok(payload) - } } } @@ -2244,7 +3585,7 @@ extension TerminalController { return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) } - func v2BrowserClick(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserClick(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "click") { selectorLiteral in """ (() => { @@ -2262,7 +3603,7 @@ extension TerminalController { } } - func v2BrowserDblClick(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserDblClick(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "dblclick") { selectorLiteral in """ (() => { @@ -2276,7 +3617,7 @@ extension TerminalController { } } - func v2BrowserHover(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserHover(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "hover") { selectorLiteral in """ (() => { @@ -2291,7 +3632,7 @@ extension TerminalController { } } - func v2BrowserFocusElement(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFocusElement(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "focus") { selectorLiteral in """ (() => { @@ -2326,7 +3667,7 @@ extension TerminalController { } """ - func v2BrowserType(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserType(params: [String: Any]) -> V2CallResult { guard let text = v2String(params, "text") else { return .err(code: "invalid_params", message: "Missing text", data: nil) } @@ -2352,7 +3693,7 @@ extension TerminalController { } } - func v2BrowserFill(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFill(params: [String: Any]) -> V2CallResult { // `fill` must allow empty strings so callers can clear existing input values. guard let text = v2RawString(params, "text") ?? v2RawString(params, "value") else { return .err(code: "invalid_params", message: "Missing text/value", data: nil) @@ -2378,7 +3719,7 @@ extension TerminalController { } } - func v2BrowserPress(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserPress(params: [String: Any]) -> V2CallResult { guard let key = v2String(params, "key") else { return .err(code: "invalid_params", message: "Missing key", data: nil) } @@ -2412,7 +3753,7 @@ extension TerminalController { } } - func v2BrowserKeyDown(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserKeyDown(params: [String: Any]) -> V2CallResult { guard let key = v2String(params, "key") else { return .err(code: "invalid_params", message: "Missing key", data: nil) } @@ -2443,7 +3784,7 @@ extension TerminalController { } } - func v2BrowserKeyUp(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserKeyUp(params: [String: Any]) -> V2CallResult { guard let key = v2String(params, "key") else { return .err(code: "invalid_params", message: "Missing key", data: nil) } @@ -2474,7 +3815,7 @@ extension TerminalController { } } - func v2BrowserCheck(params: [String: Any], checked: Bool) -> V2CallResult { + nonisolated func v2BrowserCheck(params: [String: Any], checked: Bool) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: checked ? "check" : "uncheck") { selectorLiteral in """ (() => { @@ -2490,7 +3831,7 @@ extension TerminalController { } } - func v2BrowserSelect(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserSelect(params: [String: Any]) -> V2CallResult { let selectedValue = v2String(params, "value") ?? v2String(params, "text") guard let selectedValue else { return .err(code: "invalid_params", message: "Missing value", data: nil) @@ -2512,7 +3853,7 @@ extension TerminalController { } } - func v2BrowserScroll(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserScroll(params: [String: Any]) -> V2CallResult { let dx = v2Int(params, "dx") ?? 0 let dy = v2Int(params, "dy") ?? 0 let selectorRaw = v2BrowserSelector(params) @@ -2575,7 +3916,7 @@ extension TerminalController { } } - func v2BrowserScrollIntoView(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserScrollIntoView(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "scroll_into_view") { selectorLiteral in """ (() => { @@ -2588,50 +3929,174 @@ extension TerminalController { } } - func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult { - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - let snapshotResult: Data?? = v2AwaitCallback(timeout: 5.0) { finish in - browserPanel.takeSnapshot { image in - finish(image.flatMap { self.v2PNGData(from: $0) }) +#if DEBUG + private nonisolated func v2BrowserScreenshotDebugGate( + params: [String: Any] + ) -> V2BrowserScreenshotDebugGate? { + guard let pendingMarkerPath = v2String(params, "_test_screenshot_pending_marker_path"), + let releaseMarkerPath = v2String(params, "_test_screenshot_release_marker_path") else { + return nil + } + let standardizedPendingPath = URL(fileURLWithPath: pendingMarkerPath).standardizedFileURL.path + let standardizedReleasePath = URL(fileURLWithPath: releaseMarkerPath).standardizedFileURL.path + guard standardizedPendingPath != standardizedReleasePath else { return nil } + return V2BrowserScreenshotDebugGate( + pendingMarkerPath: standardizedPendingPath, + releaseMarkerPath: standardizedReleasePath + ) + } +#endif + + private nonisolated static func v2BrowserRouteScreenshotCapture( + _ outcome: V2BrowserScreenshotCaptureOutcome, + state: V2BrowserScreenshotWaitState, + debugGate: V2BrowserScreenshotDebugGate? + ) { +#if DEBUG + if let debugGate { + let queue = DispatchQueue(label: "com.darkroom.programa.browser-screenshot-test-gate", qos: .utility) + queue.async { + let markerBytes = Array("pending".utf8) + let markerDescriptor = debugGate.pendingMarkerPath.withCString { + Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode_t(0o600)) + } + guard markerDescriptor >= 0 else { + state.complete(outcome) + return + } + + let wroteAllBytes = markerBytes.withUnsafeBytes { buffer -> Bool in + guard let baseAddress = buffer.baseAddress else { return false } + var offset = 0 + while offset < buffer.count { + let written = Darwin.write( + markerDescriptor, + baseAddress.advanced(by: offset), + buffer.count - offset + ) + if written > 0 { + offset += written + } else if written < 0, errno == EINTR { + continue + } else { + return false + } + } + return true + } + let closedSuccessfully = Darwin.close(markerDescriptor) == 0 + guard wroteAllBytes, closedSuccessfully else { + state.complete(outcome) + return + } + + let safetyDeadline = ProcessInfo.processInfo.systemUptime + 8.0 + while ProcessInfo.processInfo.systemUptime < safetyDeadline, + !Self.v2BrowserDownloadPathIsReady(debugGate.releaseMarkerPath) { + Thread.sleep(forTimeInterval: 0.01) } + state.complete(outcome) } + return + } +#endif + state.complete(outcome) + } - guard let snapshotResult else { - return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) + nonisolated func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult { +#if DEBUG + let debugGate = v2BrowserScreenshotDebugGate(params: params) +#else + let debugGate: V2BrowserScreenshotDebugGate? = nil +#endif + let state = V2BrowserScreenshotWaitState() + let startOutcome: V2BrowserScreenshotStartOutcome = v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .failed(.tabManagerUnavailable) } - guard let imageData = snapshotResult else { - return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) + guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .failed(.workspaceNotFound) + } + let surfaceId = v2UUID(params, "surface_id") ?? workspace.focusedPanelId + guard let surfaceId else { + return .failed(.noFocusedSurface) + } + guard let browserPanel = workspace.browserPanel(for: surfaceId) else { + return .failed(.surfaceNotBrowser(surfaceId)) } - var result: [String: Any] = [ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "png_base64": imageData.base64EncodedString() - ] - - // Best effort: keep screenshot data available even when temp-file writes fail. - let screenshotsDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-browser-screenshots", isDirectory: true) - if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil { - bestEffortPruneTemporaryFiles(in: screenshotsDirectory) - let timestampMs = Int(Date().timeIntervalSince1970 * 1000) - let shortSurfaceId = String(surfaceId.uuidString.prefix(8)) - let shortRandomId = String(UUID().uuidString.prefix(8)) - let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png" - let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false) - if (try? imageData.write(to: imageURL, options: .atomic)) != nil { - result["path"] = imageURL.path - result["url"] = imageURL.absoluteString + browserPanel.takeSnapshot { image in + let captureOutcome: V2BrowserScreenshotCaptureOutcome + if let image, let imageData = Self.v2PNGData(from: image) { + captureOutcome = .captured(imageData) + } else { + captureOutcome = .failed } + Self.v2BrowserRouteScreenshotCapture( + captureOutcome, + state: state, + debugGate: debugGate + ) } + return .started(V2BrowserScreenshotContext(workspaceId: workspace.id, surfaceId: surfaceId)) + } + + let context: V2BrowserScreenshotContext + switch startOutcome { + case .started(let startedContext): + context = startedContext + case .failed(.tabManagerUnavailable): + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .failed(.workspaceNotFound): + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .failed(.noFocusedSurface): + return .err(code: "not_found", message: "No focused browser surface", data: nil) + case .failed(.surfaceNotBrowser(let surfaceId)): + return .err( + code: "invalid_params", + message: "Surface is not a browser", + data: ["surface_id": surfaceId.uuidString] + ) + } + + let imageData: Data + switch state.wait(timeout: 5.0) { + case .captured(let capturedData): + imageData = capturedData + case .failed: + return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) + case .timedOut: + return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) + } + + var result: [String: Any] = [ + "workspace_id": context.workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: context.workspaceId), + "surface_id": context.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: context.surfaceId), + "png_base64": imageData.base64EncodedString() + ] - return .ok(result) + // Best effort: keep screenshot data available even when temp-file writes fail. + let screenshotsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-screenshots", isDirectory: true) + if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil { + Self.bestEffortPruneTemporaryFiles(in: screenshotsDirectory) + let timestampMs = Int(Date().timeIntervalSince1970 * 1000) + let shortSurfaceId = String(context.surfaceId.uuidString.prefix(8)) + let shortRandomId = String(UUID().uuidString.prefix(8)) + let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png" + let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false) + if (try? imageData.write(to: imageURL, options: .atomic)) != nil { + result["path"] = imageURL.path + result["url"] = imageURL.absoluteString + } } + + return .ok(result) } - func v2BrowserGetText(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetText(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "get.text") { selectorLiteral in """ (() => { @@ -2643,7 +4108,7 @@ extension TerminalController { } } - func v2BrowserGetHTML(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetHTML(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "get.html") { selectorLiteral in """ (() => { @@ -2655,7 +4120,7 @@ extension TerminalController { } } - func v2BrowserGetValue(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetValue(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "get.value") { selectorLiteral in """ (() => { @@ -2668,7 +4133,7 @@ extension TerminalController { } } - func v2BrowserGetAttr(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetAttr(params: [String: Any]) -> V2CallResult { guard let attr = v2String(params, "attr") ?? v2String(params, "name") else { return .err(code: "invalid_params", message: "Missing attr/name", data: nil) } @@ -2684,7 +4149,7 @@ extension TerminalController { } } - func v2BrowserGetTitle(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetTitle(params: [String: Any]) -> V2CallResult { v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in .ok([ "workspace_id": ws.id.uuidString, @@ -2696,7 +4161,7 @@ extension TerminalController { } } - func v2BrowserGetCount(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetCount(params: [String: Any]) -> V2CallResult { guard let selectorRaw = v2BrowserSelector(params) else { return .err(code: "invalid_params", message: "Missing selector", data: nil) } @@ -2706,7 +4171,12 @@ extension TerminalController { } let selectorLiteral = v2JSONLiteral(selector) let script = "document.querySelectorAll(\(selectorLiteral)).length" - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript( + browserPanel.webView, + surfaceId: surfaceId, + script: script, + useEval: false + ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): @@ -2722,7 +4192,7 @@ extension TerminalController { } } - func v2BrowserGetBox(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetBox(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "get.box") { selectorLiteral in """ (() => { @@ -2735,7 +4205,7 @@ extension TerminalController { } } - func v2BrowserGetStyles(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGetStyles(params: [String: Any]) -> V2CallResult { let property = v2String(params, "property") return v2BrowserSelectorAction(params: params, actionName: "get.styles") { selectorLiteral in if let property { @@ -2768,7 +4238,7 @@ extension TerminalController { } } - func v2BrowserIsVisible(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserIsVisible(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "is.visible") { selectorLiteral in """ (() => { @@ -2783,7 +4253,7 @@ extension TerminalController { } } - func v2BrowserIsEnabled(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserIsEnabled(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "is.enabled") { selectorLiteral in """ (() => { @@ -2796,7 +4266,7 @@ extension TerminalController { } } - func v2BrowserIsChecked(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserIsChecked(params: [String: Any]) -> V2CallResult { v2BrowserSelectorAction(params: params, actionName: "is.checked") { selectorLiteral in """ (() => { @@ -2810,18 +4280,22 @@ extension TerminalController { } - func v2BrowserNavSimple(params: [String: Any], action: String) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - - var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString]) - v2MainSync { + nonisolated func v2BrowserNavSimple(params: [String: Any], action: String) -> V2CallResult { + return v2MainSync { () -> V2CallResult in + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return v2InvalidParam("surface_id") + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), - let browserPanel = ws.browserPanel(for: surfaceId) else { return } + let browserPanel = ws.browserPanel(for: surfaceId) else { + return .err( + code: "not_found", + message: "Surface not found or not a browser", + data: ["surface_id": surfaceId.uuidString] + ) + } switch action { case "back": browserPanel.goBack() @@ -2841,24 +4315,27 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) ] v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload) - result = .ok(payload) + return .ok(payload) } - return result } - func v2BrowserGetURL(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - - var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString]) - v2MainSync { + nonisolated func v2BrowserGetURL(params: [String: Any]) -> V2CallResult { + return v2MainSync { () -> V2CallResult in + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return v2InvalidParam("surface_id") + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), - let browserPanel = ws.browserPanel(for: surfaceId) else { return } - result = .ok([ + let browserPanel = ws.browserPanel(for: surfaceId) else { + return .err( + code: "not_found", + message: "Surface not found or not a browser", + data: ["surface_id": surfaceId.uuidString] + ) + } + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, @@ -2866,7 +4343,6 @@ extension TerminalController { "url": browserPanel.currentURL?.absoluteString ?? "" ]) } - return result } func v2BrowserFocusWebView(params: [String: Any]) -> V2CallResult { @@ -2958,13 +4434,14 @@ extension TerminalController { return .ok(["focused": focused]) } - func v2BrowserFindWithScript( + nonisolated func v2BrowserFindWithScript( params: [String: Any], actionName: String, finderBody: String, - metadata: [String: Any] = [:] + metadataBuilder: @MainActor @Sendable () -> [String: Any] = { [:] } ) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in + let metadata = metadataBuilder() let script = """ (() => { const __programaCssPath = (el) => { @@ -3019,32 +4496,33 @@ extension TerminalController { return .err(code: "not_found", message: "Element not found", data: metadata) } - let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector) - var payload: [String: Any] = [ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "action": actionName, - "selector": selector, - "element_ref": ref, - "ref": ref - ] - for (k, v) in metadata { - payload[k] = v - } - if let tag = dict["tag"] as? String { - payload["tag"] = tag - } - if let text = dict["text"] as? String { - payload["text"] = text + return v2BrowserWithAllocatedElementRef(surfaceId: surfaceId, selector: selector) { ref in + var payload: [String: Any] = [ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "action": actionName, + "selector": selector, + "element_ref": ref, + "ref": ref + ] + for (k, v) in metadata { + payload[k] = v + } + if let tag = dict["tag"] as? String { + payload["tag"] = tag + } + if let text = dict["text"] as? String { + payload["text"] = text + } + return .ok(payload) } - return .ok(payload) } } } - func v2BrowserFindRole(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindRole(params: [String: Any]) -> V2CallResult { guard let role = (v2String(params, "role") ?? v2String(params, "value"))?.lowercased() else { return .err(code: "invalid_params", message: "Missing role", data: nil) } @@ -3104,15 +4582,17 @@ extension TerminalController { params: params, actionName: "find.role", finderBody: finder, - metadata: [ - "role": role, - "name": v2OrNull(name), - "exact": exact - ] + metadataBuilder: { + [ + "role": role, + "name": v2OrNull(name), + "exact": exact + ] + } ) } - func v2BrowserFindText(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindText(params: [String: Any]) -> V2CallResult { guard let text = (v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else { return .err(code: "invalid_params", message: "Missing text", data: nil) } @@ -3136,11 +4616,11 @@ extension TerminalController { params: params, actionName: "find.text", finderBody: finder, - metadata: ["text": text, "exact": exact] + metadataBuilder: { ["text": text, "exact": exact] } ) } - func v2BrowserFindLabel(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindLabel(params: [String: Any]) -> V2CallResult { guard let label = (v2String(params, "label") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else { return .err(code: "invalid_params", message: "Missing label", data: nil) } @@ -3169,11 +4649,11 @@ extension TerminalController { params: params, actionName: "find.label", finderBody: finder, - metadata: ["label": label, "exact": exact] + metadataBuilder: { ["label": label, "exact": exact] } ) } - func v2BrowserFindPlaceholder(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindPlaceholder(params: [String: Any]) -> V2CallResult { guard let placeholder = (v2String(params, "placeholder") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else { return .err(code: "invalid_params", message: "Missing placeholder", data: nil) } @@ -3196,11 +4676,11 @@ extension TerminalController { params: params, actionName: "find.placeholder", finderBody: finder, - metadata: ["placeholder": placeholder, "exact": exact] + metadataBuilder: { ["placeholder": placeholder, "exact": exact] } ) } - func v2BrowserFindAlt(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindAlt(params: [String: Any]) -> V2CallResult { guard let alt = (v2String(params, "alt") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else { return .err(code: "invalid_params", message: "Missing alt text", data: nil) } @@ -3223,11 +4703,11 @@ extension TerminalController { params: params, actionName: "find.alt", finderBody: finder, - metadata: ["alt": alt, "exact": exact] + metadataBuilder: { ["alt": alt, "exact": exact] } ) } - func v2BrowserFindTitle(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindTitle(params: [String: Any]) -> V2CallResult { guard let title = (v2String(params, "title") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else { return .err(code: "invalid_params", message: "Missing title", data: nil) } @@ -3250,11 +4730,11 @@ extension TerminalController { params: params, actionName: "find.title", finderBody: finder, - metadata: ["title": title, "exact": exact] + metadataBuilder: { ["title": title, "exact": exact] } ) } - func v2BrowserFindTestId(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindTestId(params: [String: Any]) -> V2CallResult { guard let testId = v2String(params, "testid") ?? v2String(params, "test_id") ?? v2String(params, "value") else { return .err(code: "invalid_params", message: "Missing testid", data: nil) } @@ -3277,11 +4757,11 @@ extension TerminalController { params: params, actionName: "find.testid", finderBody: finder, - metadata: ["testid": testId] + metadataBuilder: { ["testid": testId] } ) } - func v2BrowserFindFirst(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindFirst(params: [String: Any]) -> V2CallResult { guard let selectorRaw = v2BrowserSelector(params) else { return .err(code: "invalid_params", message: "Missing selector", data: nil) } @@ -3306,22 +4786,23 @@ extension TerminalController { ok else { return .err(code: "not_found", message: "Element not found", data: ["selector": selector]) } - let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector) - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "selector": selector, - "element_ref": ref, - "ref": ref, - "text": v2OrNull(dict["text"]) - ]) + return v2BrowserWithAllocatedElementRef(surfaceId: surfaceId, selector: selector) { ref in + .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "selector": selector, + "element_ref": ref, + "ref": ref, + "text": v2OrNull(dict["text"]) + ]) + } } } } - func v2BrowserFindLast(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindLast(params: [String: Any]) -> V2CallResult { guard let selectorRaw = v2BrowserSelector(params) else { return .err(code: "invalid_params", message: "Missing selector", data: nil) } @@ -3351,22 +4832,23 @@ extension TerminalController { !finalSelector.isEmpty else { return .err(code: "not_found", message: "Element not found", data: ["selector": selector]) } - let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: finalSelector) - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "selector": finalSelector, - "element_ref": ref, - "ref": ref, - "text": v2OrNull(dict["text"]) - ]) + return v2BrowserWithAllocatedElementRef(surfaceId: surfaceId, selector: finalSelector) { ref in + .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "selector": finalSelector, + "element_ref": ref, + "ref": ref, + "text": v2OrNull(dict["text"]) + ]) + } } } } - func v2BrowserFindNth(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFindNth(params: [String: Any]) -> V2CallResult { guard let selectorRaw = v2BrowserSelector(params) else { return .err(code: "invalid_params", message: "Missing selector", data: nil) } @@ -3403,23 +4885,24 @@ extension TerminalController { !finalSelector.isEmpty else { return .err(code: "not_found", message: "Element not found", data: ["selector": selector, "index": index]) } - let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: finalSelector) - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "selector": finalSelector, - "element_ref": ref, - "ref": ref, - "index": v2OrNull(dict["index"]), - "text": v2OrNull(dict["text"]) - ]) + return v2BrowserWithAllocatedElementRef(surfaceId: surfaceId, selector: finalSelector) { ref in + .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "selector": finalSelector, + "element_ref": ref, + "ref": ref, + "index": v2OrNull(dict["index"]), + "text": v2OrNull(dict["text"]) + ]) + } } } } - func v2BrowserFrameSelect(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFrameSelect(params: [String: Any]) -> V2CallResult { guard let selectorRaw = v2BrowserSelector(params) else { return .err(code: "invalid_params", message: "Missing selector", data: nil) } @@ -3428,6 +4911,13 @@ extension TerminalController { guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else { return v2BrowserSelectorResolutionError(selectorRaw, surfaceId: surfaceId) } + guard selector.utf8.count <= Self.v2BrowserElementRefSelectorByteLimit else { + return .err( + code: "invalid_params", + message: "Frame selector exceeds the supported size limit", + data: ["selector_byte_limit": Self.v2BrowserElementRefSelectorByteLimit] + ) + } let selectorLiteral = v2JSONLiteral(selector) let script = """ (() => { @@ -3443,25 +4933,27 @@ extension TerminalController { return { ok: true }; })() """ - switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) { + switch v2RunBrowserJavaScript( + browserPanel.webView, + surfaceId: surfaceId, + script: script, + useEval: false + ) { case .failure(let message): return .err(code: "js_error", message: message, data: nil) case .success(let value): if let dict = value as? [String: Any], let ok = dict["ok"] as? Bool, ok { - switch v2BrowserApplyFrameSelector( + guard case .applied = v2BrowserApplyFrameSelector( selector, surfaceId: surfaceId, source: .frameSelect - ) { - case .applied: - break - case .rejected(let limit): + ) else { return .err( code: "invalid_params", - message: "Frame selector exceeds \(limit) bytes", - data: ["selector": selector] + message: "Frame selector exceeds the supported size limit", + data: ["selector_byte_limit": Self.v2BrowserElementRefSelectorByteLimit] ) } return .ok([ @@ -3482,7 +4974,7 @@ extension TerminalController { } } - func v2BrowserFrameMain(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserFrameMain(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId) return .ok([ @@ -3564,131 +5056,168 @@ extension TerminalController { } } - func v2BrowserDownloadWait(params: [String: Any]) -> V2CallResult { - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in - let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 10_000) - let timeout = Double(timeoutMs) / 1000.0 - let path = v2String(params, "path") - - if let path { - let fm = FileManager.default - let pathIsReady = { - guard fm.fileExists(atPath: path), - let attrs = try? fm.attributesOfItem(atPath: path), - let size = attrs[.size] as? NSNumber else { - return false - } - return size.intValue > 0 - } - if pathIsReady() { - return .ok([ + private nonisolated func v2BrowserDownloadPathLookup(params: [String: Any]) -> V2BrowserDownloadPathLookup { + v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .failed(.tabManagerUnavailable) + } + guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .failed(.workspaceNotFound) + } + let surfaceId = v2UUID(params, "surface_id") ?? workspace.focusedPanelId + guard let surfaceId else { + return .failed(.noFocusedSurface) + } + guard workspace.browserPanel(for: surfaceId) != nil else { + return .failed(.surfaceNotBrowser(surfaceId)) + } + return .resolved(V2BrowserDownloadPathContext(workspaceId: workspace.id, surfaceId: surfaceId)) + } + } + + private nonisolated static func v2BrowserDownloadPathIsReady(_ path: String) -> Bool { + var attributes = stat() + let exists = path.withCString { Darwin.fstatat(AT_FDCWD, $0, &attributes, 0) == 0 } + return exists && attributes.st_size > 0 + } + + private nonisolated static func v2WaitForBrowserDownloadPath( + _ path: String, + timeout: TimeInterval, + pendingMarkerPath: String? + ) -> V2BrowserDownloadPathWaitResult { + if v2BrowserDownloadPathIsReady(path) { + return .ready + } + + let watchedPath = URL(fileURLWithPath: path).deletingLastPathComponent().path + let fileDescriptor = open(watchedPath, O_EVTONLY) + guard fileDescriptor >= 0 else { + return .failedToWatch + } + + let queue = DispatchQueue(label: "com.darkroom.programa.browser-download-path-wait", qos: .utility) + let state = V2BrowserDownloadPathWaitState(fileDescriptor: fileDescriptor) + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: [.write, .extend, .attrib, .link, .rename], + queue: queue + ) + source.setEventHandler { + if v2BrowserDownloadPathIsReady(path) { + state.finish(ready: true) + } + } + source.setCancelHandler { + state.closeFileDescriptorAndAcknowledgeCancellation() + } + state.install(source: source) + source.resume() + + queue.async { + if v2BrowserDownloadPathIsReady(path) { + state.finish(ready: true) + return + } +#if DEBUG + if let pendingMarkerPath, + URL(fileURLWithPath: pendingMarkerPath).standardizedFileURL.path + != URL(fileURLWithPath: path).standardizedFileURL.path { + try? Data("pending".utf8).write(to: URL(fileURLWithPath: pendingMarkerPath), options: .atomic) + } +#endif + } + queue.asyncAfter(deadline: .now() + timeout) { + state.finish(ready: v2BrowserDownloadPathIsReady(path)) + } + + let ready = state.waitForResult() + state.waitForCancellationAcknowledgement() + return ready ? .ready : .timedOut + } + + nonisolated func v2BrowserDownloadWait(params: [String: Any]) -> V2CallResult { + let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 10_000) + let timeout = Double(timeoutMs) / 1000.0 + guard let path = v2String(params, "path") else { + return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in + switch v2BrowserWaitForDownloadEvent(surfaceId: surfaceId, timeout: timeout) { + case .event(let event, let droppedEvents): + var payload: [String: Any] = [ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "path": path, - "downloaded": true - ]) - } - - let watchedPath = URL(fileURLWithPath: path).deletingLastPathComponent().path - let fd = open(watchedPath, O_EVTONLY) - guard fd >= 0 else { - return .err(code: "internal_error", message: "Failed to watch download path", data: ["path": path]) - } - - let ready = v2AwaitCallback(timeout: timeout) { finish in - var source: DispatchSourceFileSystemObject? - var timeoutWorkItem: DispatchWorkItem? - var finished = false - let finishOnce: (Bool) -> Void = { value in - guard !finished else { return } - finished = true - timeoutWorkItem?.cancel() - source?.cancel() - finish(value) + "download": event + ] + if droppedEvents > 0 { + payload["dropped_events"] = droppedEvents } - source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .extend, .attrib, .link, .rename], - queue: .main + return .ok(payload) + case .timedOut: + return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + case .cancelled: + return .err( + code: "not_found", + message: "Browser surface closed while waiting for a download", + data: ["surface_id": surfaceId.uuidString] + ) + case .busy: + return .err( + code: "busy", + message: "Another event-mode download wait is already active", + data: ["surface_id": surfaceId.uuidString] ) - source?.setEventHandler { - if pathIsReady() { - finishOnce(true) - } - } - source?.setCancelHandler { - // Close here, not in an outer `defer`: two asyncAfter timeouts (this - // one and v2AwaitCallback's) can race to tear this down, and closing - // in a defer scoped to the whole function could run while the source - // is still uncancelled. - Darwin.close(fd) - source = nil - } - source?.resume() - timeoutWorkItem = DispatchWorkItem { - finishOnce(pathIsReady()) - } - if let timeoutWorkItem { - DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) - } - if pathIsReady() { - finishOnce(true) - } - } ?? false - guard ready else { - return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) } - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "path": path, - "downloaded": true - ]) } + } - if let first = v2BrowserDownloadEventsBySurface[surfaceId]?.first { - var remaining = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - remaining.removeFirst() - v2BrowserDownloadEventsBySurface[surfaceId] = remaining - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "download": first - ]) - } +#if DEBUG + let pendingMarkerPath = v2String(params, "_test_pending_marker_path") +#else + let pendingMarkerPath: String? = nil +#endif - var waitState: BrowserDownloadWaitState? - let downloadEvent = v2AwaitCallback(timeout: timeout) { finish in - let state = BrowserDownloadWaitState(surfaceId: surfaceId, finish: finish) - waitState = state - let observer = NotificationCenter.default.addObserver( - forName: .browserDownloadEventDidArrive, - object: nil, - queue: .main - ) { [state] note in - MainActor.assumeIsolated { - state.receive(note) - } - } - state.install(observer) - } - waitState?.cancel() - guard let downloadEvent else { - return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) - } + let context: V2BrowserDownloadPathContext + switch v2BrowserDownloadPathLookup(params: params) { + case .resolved(let resolvedContext): + context = resolvedContext + case .failed(.tabManagerUnavailable): + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .failed(.workspaceNotFound): + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .failed(.noFocusedSurface): + return .err(code: "not_found", message: "No focused browser surface", data: nil) + case .failed(.surfaceNotBrowser(let surfaceId)): + return .err( + code: "invalid_params", + message: "Surface is not a browser", + data: ["surface_id": surfaceId.uuidString] + ) + } + + switch Self.v2WaitForBrowserDownloadPath( + path, + timeout: timeout, + pendingMarkerPath: pendingMarkerPath + ) { + case .ready: return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "download": downloadEvent + "workspace_id": context.workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: context.workspaceId), + "surface_id": context.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: context.surfaceId), + "path": path, + "downloaded": true ]) + case .timedOut: + return .err( + code: "timeout", + message: "Timed out waiting for download file", + data: ["path": path, "timeout_ms": timeoutMs] + ) + case .failedToWatch: + return .err(code: "internal_error", message: "Failed to watch download path", data: ["path": path]) } } @@ -3883,12 +5412,12 @@ extension TerminalController { } } - func v2BrowserStorageType(_ params: [String: Any]) -> String { + nonisolated func v2BrowserStorageType(_ params: [String: Any]) -> String { let type = (v2String(params, "storage") ?? v2String(params, "type") ?? "local").lowercased() return (type == "session") ? "session" : "local" } - func v2BrowserStorageGet(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserStorageGet(params: [String: Any]) -> V2CallResult { let storageType = v2BrowserStorageType(params) let key = v2String(params, "key") return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in @@ -3933,19 +5462,19 @@ extension TerminalController { } } - func v2BrowserStorageSet(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserStorageSet(params: [String: Any]) -> V2CallResult { let storageType = v2BrowserStorageType(params) guard let key = v2String(params, "key") else { return .err(code: "invalid_params", message: "Missing key", data: nil) } - guard let value = params["value"] else { + guard params.keys.contains("value") else { return .err(code: "invalid_params", message: "Missing value", data: nil) } return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in let typeLiteral = v2JSONLiteral(storageType) let keyLiteral = v2JSONLiteral(key) - let valueLiteral = v2JSONLiteral(v2NormalizeJSValue(value)) + let valueLiteral = v2JSONLiteral(v2NormalizeJSValue(params["value"])) let script = """ (() => { const type = String(\(typeLiteral)); @@ -3978,7 +5507,7 @@ extension TerminalController { } } - func v2BrowserStorageClear(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserStorageClear(params: [String: Any]) -> V2CallResult { let storageType = v2BrowserStorageType(params) return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in let typeLiteral = v2JSONLiteral(storageType) @@ -4188,10 +5717,10 @@ extension TerminalController { return result } - func v2BrowserConsoleList(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserConsoleList(params: [String: Any]) -> V2CallResult { + let clear = v2Bool(params, "clear") ?? false return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel) - let clear = v2Bool(params, "clear") ?? false let clearLiteral = clear ? "true" : "false" let script = """ (() => { @@ -4220,16 +5749,16 @@ extension TerminalController { } } - func v2BrowserConsoleClear(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserConsoleClear(params: [String: Any]) -> V2CallResult { var withClear = params withClear["clear"] = true return v2BrowserConsoleList(params: withClear) } - func v2BrowserErrorsList(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserErrorsList(params: [String: Any]) -> V2CallResult { + let clear = v2Bool(params, "clear") ?? false return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel) - let clear = v2Bool(params, "clear") ?? false let clearLiteral = clear ? "true" : "false" let script = """ (() => { @@ -4258,7 +5787,7 @@ extension TerminalController { } } - func v2BrowserHighlight(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserHighlight(params: [String: Any]) -> V2CallResult { return v2BrowserSelectorAction(params: params, actionName: "highlight") { selectorLiteral in """ (() => { @@ -4618,7 +6147,7 @@ extension TerminalController { return .err(code: code, message: failure.message, data: data) } - func v2BrowserAddInitScript(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserAddInitScript(params: [String: Any]) -> V2CallResult { guard let script = v2String(params, "script") ?? v2String(params, "content") else { return .err(code: "invalid_params", message: "Missing script", data: nil) } @@ -4641,7 +6170,7 @@ extension TerminalController { } } - func v2BrowserAddScript(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserAddScript(params: [String: Any]) -> V2CallResult { guard let script = v2String(params, "script") ?? v2String(params, "content") else { return .err(code: "invalid_params", message: "Missing script", data: nil) } @@ -4661,7 +6190,7 @@ extension TerminalController { } } - func v2BrowserAddStyle(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserAddStyle(params: [String: Any]) -> V2CallResult { guard let css = v2String(params, "css") ?? v2String(params, "style") ?? v2String(params, "content") else { return .err(code: "invalid_params", message: "Missing css/style content", data: nil) } @@ -4694,43 +6223,49 @@ extension TerminalController { } } - func v2BrowserViewportSet(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserViewportSet(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.viewport.set", details: "WKWebView does not provide a per-tab programmable viewport emulation API equivalent to CDP") } - func v2BrowserGeolocationSet(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserGeolocationSet(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.geolocation.set", details: "WKWebView does not expose per-tab geolocation spoofing hooks equivalent to Playwright/CDP") } - func v2BrowserOfflineSet(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserOfflineSet(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.offline.set", details: "WKWebView does not expose reliable per-tab offline emulation") } - func v2BrowserTraceStart(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserTraceStart(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.trace.start", details: "Playwright trace artifacts are not available on WKWebView") } - func v2BrowserTraceStop(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserTraceStop(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.trace.stop", details: "Playwright trace artifacts are not available on WKWebView") } - func v2BrowserNetworkRoute(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserNetworkRoute(params: [String: Any]) -> V2CallResult { if let surfaceId = v2UUID(params, "surface_id") { - v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "route", "params": params]) + v2MainSync { + v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "route", "params": params]) + } } return v2BrowserNotSupported("browser.network.route", details: "WKWebView does not provide CDP-style request interception/mocking") } - func v2BrowserNetworkUnroute(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserNetworkUnroute(params: [String: Any]) -> V2CallResult { if let surfaceId = v2UUID(params, "surface_id") { - v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "unroute", "params": params]) + v2MainSync { + v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "unroute", "params": params]) + } } return v2BrowserNotSupported("browser.network.unroute", details: "WKWebView does not provide CDP-style request interception/mocking") } - func v2BrowserNetworkRequests(params: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserNetworkRequests(params: [String: Any]) -> V2CallResult { if let surfaceId = v2UUID(params, "surface_id") { - let items = v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? [] + let items: [[String: Any]] = v2MainSync { + v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? [] + } return .err(code: "not_supported", message: "browser.network.requests is not supported on WKWebView", data: [ "details": "Request interception logs are unavailable without CDP network hooks", "recorded_requests": items @@ -4739,23 +6274,23 @@ extension TerminalController { return v2BrowserNotSupported("browser.network.requests", details: "Request interception logs are unavailable without CDP network hooks") } - func v2BrowserScreencastStart(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserScreencastStart(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.screencast.start", details: "WKWebView does not expose CDP screencast streaming") } - func v2BrowserScreencastStop(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserScreencastStop(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.screencast.stop", details: "WKWebView does not expose CDP screencast streaming") } - func v2BrowserInputMouse(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserInputMouse(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.input_mouse", details: "Raw CDP mouse injection is unavailable; use browser.click/hover/scroll") } - func v2BrowserInputKeyboard(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserInputKeyboard(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.input_keyboard", details: "Raw CDP keyboard injection is unavailable; use browser.press/keydown/keyup") } - func v2BrowserInputTouch(params _: [String: Any]) -> V2CallResult { + nonisolated func v2BrowserInputTouch(params _: [String: Any]) -> V2CallResult { v2BrowserNotSupported("browser.input_touch", details: "Raw CDP touch injection is unavailable on WKWebView") } diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 48b70139..7884e673 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -73,7 +73,7 @@ extension TerminalController { #if DEBUG // MARK: - V2 Debug / Test-only Methods - func v2DebugGlassSet(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugGlassSet(params: [String: Any]) -> V2CallResult { guard let rawSurface = v2String(params, "surface"), let surface = ProgramaGlassSurface(commandValue: rawSurface), let enabled = params["enabled"] as? Bool else { @@ -84,22 +84,26 @@ extension TerminalController { ) } - ProgramaGlassSettings.setDebugEnabled(enabled, for: surface) - return .ok(["surface": surface.rawValue, "enabled": enabled]) + return v2MainSync { + ProgramaGlassSettings.setDebugEnabled(enabled, for: surface) + return .ok(["surface": surface.rawValue, "enabled": enabled]) + } } - func v2DebugShortcutSet(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugShortcutSet(params: [String: Any]) -> V2CallResult { guard let name = v2String(params, "name"), let combo = v2String(params, "combo") else { return .err(code: "invalid_params", message: "Missing name/combo", data: nil) } - let resp = setShortcut("\(name) \(combo)") - return resp == "OK" - ? .ok([:]) - : .err(code: "internal_error", message: resp, data: nil) + return v2MainSync { + let resp = setShortcut("\(name) \(combo)") + return resp == "OK" + ? .ok([:]) + : .err(code: "internal_error", message: resp, data: nil) + } } - func v2DebugShortcutSimulate(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugShortcutSimulate(params: [String: Any]) -> V2CallResult { guard let combo = v2String(params, "combo") else { return .err(code: "invalid_params", message: "Missing combo", data: nil) } @@ -109,75 +113,67 @@ extension TerminalController { : .err(code: "internal_error", message: resp, data: nil) } - func v2DebugType(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugType(params: [String: Any]) -> V2CallResult { guard let text = params["text"] as? String else { return .err(code: "invalid_params", message: "Missing text", data: nil) } - var result: V2CallResult = .err(code: "internal_error", message: "No window", data: nil) - DispatchQueue.main.sync { + return v2MainSync { guard let window = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first(where: { $0.isVisible }) ?? NSApp.windows.first else { - result = .err(code: "not_found", message: "No window", data: nil) - return + return .err(code: "not_found", message: "No window", data: nil) } if socketCommandAllowsInAppFocusMutations() { NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) } guard let fr = window.firstResponder else { - result = .err(code: "not_found", message: "No first responder", data: nil) - return + return .err(code: "not_found", message: "No first responder", data: nil) } if let client = fr as? NSTextInputClient { client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - result = .ok([:]) - return + return .ok([:]) } fr.insertText(text) - result = .ok([:]) + return .ok([:]) } - return result } - func v2DebugActivateApp() -> V2CallResult { + nonisolated func v2DebugActivateApp() -> V2CallResult { let resp = activateApp() return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } - func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { let requestedWindowId = v2UUID(params, "window_id") - var result: V2CallResult = .ok([:]) - v2MainSync { + return v2MainSync { let targetWindow: NSWindow? if let requestedWindowId { guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { - result = .err( + return .err( code: "not_found", message: "Window not found", data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)] ) - return } targetWindow = window } else { targetWindow = NSApp.keyWindow ?? NSApp.mainWindow } NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow) + return .ok([:]) } - return result } - func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { let requestedWindowId = v2UUID(params, "window_id") - var result: V2CallResult = .ok([:]) - DispatchQueue.main.sync { + return v2MainSync { let targetWindow: NSWindow? if let requestedWindowId { guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { - result = .err( + return .err( code: "not_found", message: "Window not found", data: [ @@ -185,96 +181,86 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) ] ) - return } targetWindow = window } else { targetWindow = NSApp.keyWindow ?? NSApp.mainWindow } NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + return .ok([:]) } - return result } - func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } - var visible = false - DispatchQueue.main.sync { - visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + return v2MainSync { + let visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) } - return .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId), - "visible": visible - ]) } - func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } - var visible = false - var selectedIndex = 0 - DispatchQueue.main.sync { - visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false - selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + return v2MainSync { + let visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + let selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex) + ]) } - return .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId), - "visible": visible, - "selected_index": max(0, selectedIndex) - ]) } - func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } let requestedLimit = params["limit"] as? Int let limit = max(1, min(100, requestedLimit ?? 20)) - var visible = false - var selectedIndex = 0 - var snapshot = CommandPaletteDebugSnapshot.empty - - DispatchQueue.main.sync { - visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false - selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 - snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty - } + return v2MainSync { + let visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + let selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + let snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty + let rows = Array(snapshot.results.prefix(limit)).map { row in + [ + "command_id": row.commandId, + "title": row.title, + "shortcut_hint": v2OrNull(row.shortcutHint), + "trailing_label": v2OrNull(row.trailingLabel), + "score": row.score + ] as [String: Any] + } - let rows = Array(snapshot.results.prefix(limit)).map { row in - [ - "command_id": row.commandId, - "title": row.title, - "shortcut_hint": v2OrNull(row.shortcutHint), - "trailing_label": v2OrNull(row.trailingLabel), - "score": row.score - ] as [String: Any] + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex), + "query": snapshot.query, + "mode": snapshot.mode, + "results": rows + ]) } - - return .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId), - "visible": visible, - "selected_index": max(0, selectedIndex), - "query": snapshot.query, - "mode": snapshot.mode, - "results": rows - ]) } - func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { let requestedWindowId = v2UUID(params, "window_id") - var result: V2CallResult = .ok([:]) - DispatchQueue.main.sync { + return v2MainSync { let targetWindow: NSWindow? if let requestedWindowId { guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { - result = .err( + return .err( code: "not_found", message: "Window not found", data: [ @@ -282,25 +268,23 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) ] ) - return } targetWindow = window } else { targetWindow = NSApp.keyWindow ?? NSApp.mainWindow } NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow) + return .ok([:]) } - return result } - func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { let requestedWindowId = v2UUID(params, "window_id") - var result: V2CallResult = .ok([:]) - DispatchQueue.main.sync { + return v2MainSync { let targetWindow: NSWindow? if let requestedWindowId { guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { - result = .err( + return .err( code: "not_found", message: "Window not found", data: [ @@ -308,46 +292,42 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) ] ) - return } targetWindow = window } else { targetWindow = NSApp.keyWindow ?? NSApp.mainWindow } NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow) + return .ok([:]) } - return result } - func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } - var result: V2CallResult = .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId), - "focused": false, - "selection_location": 0, - "selection_length": 0, - "text_length": 0 - ]) - - DispatchQueue.main.sync { + return v2MainSync { guard let window = AppDelegate.shared?.mainWindow(for: windowId) else { - result = .err( + return .err( code: "not_found", message: "Window not found", data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] ) - return } guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { - return + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": false, + "selection_location": 0, + "selection_length": 0, + "text_length": 0 + ]) } let selectedRange = editor.selectedRange() let textLength = (editor.string as NSString).length - result = .ok([ + return .ok([ "window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId), "focused": true, @@ -356,74 +336,70 @@ extension TerminalController { "text_length": max(0, textLength) ]) } - - return result } - func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult { let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id") - var focusedSurfaceId: UUID? - DispatchQueue.main.sync { - focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId() - } - - var payload: [String: Any] = [ - "focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString), - "focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), - "focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString), - "focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), - "focused": focusedSurfaceId != nil - ] + return v2MainSync { + let focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId() + var payload: [String: Any] = [ + "focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString), + "focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId), + "focused": focusedSurfaceId != nil + ] + + if let requestedSurfaceId { + payload["surface_id"] = requestedSurfaceId.uuidString + payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["panel_id"] = requestedSurfaceId.uuidString + payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) + payload["focused"] = (focusedSurfaceId == requestedSurfaceId) + } - if let requestedSurfaceId { - payload["surface_id"] = requestedSurfaceId.uuidString - payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) - payload["panel_id"] = requestedSurfaceId.uuidString - payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId) - payload["focused"] = (focusedSurfaceId == requestedSurfaceId) + return .ok(payload) } - - return .ok(payload) } - func v2DebugBrowserFavicon(params: [String: Any]) -> V2CallResult { - return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - let pngData = browserPanel.faviconPNGData - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "has_favicon": pngData != nil, - "png_base64": pngData?.base64EncodedString() ?? "", - "current_url": v2OrNull(browserPanel.currentURL?.absoluteString) - ]) + nonisolated func v2DebugBrowserFavicon(params: [String: Any]) -> V2CallResult { + return v2MainSync { + v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in + let pngData = browserPanel.faviconPNGData + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "has_favicon": pngData != nil, + "png_base64": pngData?.base64EncodedString() ?? "", + "current_url": v2OrNull(browserPanel.currentURL?.absoluteString) + ]) + } } } - func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } - var visibility: Bool? - DispatchQueue.main.sync { - visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId) - } - guard let visible = visibility else { - return .err( - code: "not_found", - message: "Window not found", - data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] - ) + return v2MainSync { + guard let visible = AppDelegate.shared?.sidebarVisibility(windowId: windowId) else { + return .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) } - return .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId), - "visible": visible - ]) } - func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) } @@ -434,7 +410,7 @@ extension TerminalController { return .ok(["focused": resp.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"]) } - func v2DebugReadTerminalText(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugReadTerminalText(params: [String: Any]) -> V2CallResult { let surfaceArg = v2String(params, "surface_id") ?? "" let resp = readTerminalText(surfaceArg) guard resp.hasPrefix("OK ") else { @@ -444,7 +420,7 @@ extension TerminalController { return .ok(["base64": b64]) } - func v2DebugRenderStats(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugRenderStats(params: [String: Any]) -> V2CallResult { let surfaceArg = v2String(params, "surface_id") ?? "" let resp = renderStats(surfaceArg) guard resp.hasPrefix("OK ") else { @@ -458,7 +434,7 @@ extension TerminalController { return .ok(["stats": obj]) } - func v2DebugLayout() -> V2CallResult { + nonisolated func v2DebugLayout() -> V2CallResult { let resp = layoutDebug() guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) @@ -471,7 +447,7 @@ extension TerminalController { return .ok(["layout": obj]) } - func v2DebugPortalStats() -> V2CallResult { + nonisolated func v2DebugPortalStats() -> V2CallResult { let payload: [String: Any] = v2MainSync { TerminalWindowPortalRegistry.debugPortalStats() } @@ -481,7 +457,7 @@ extension TerminalController { /// Dumps the key window's AppKit view tree with frames, visibility, and any /// opaque layer background — chrome-layering bugs (a stray view painting /// over content) are otherwise invisible to log-based diagnosis. - func v2DebugViewTree() -> V2CallResult { + nonisolated func v2DebugViewTree() -> V2CallResult { let lines: [String] = v2MainSync { guard let window = NSApp.keyWindow ?? NSApp.windows.first(where: { $0.isVisible && $0.contentView != nil }) else { return [] @@ -491,6 +467,7 @@ extension TerminalController { "WINDOW isOpaque=\(window.isOpaque) bg=\(window.backgroundColor.hexString())@\(String(format: "%.3f", window.backgroundColor.alphaComponent)) " + "appearance=\(window.effectiveAppearance.name.rawValue)" ) + @MainActor func walk(_ view: NSView, depth: Int) { let frame = view.frame var line = String(repeating: " ", count: depth) @@ -519,26 +496,26 @@ extension TerminalController { return .ok(["tree": lines]) } - func v2DebugBonsplitUnderflowCount() -> V2CallResult { + nonisolated func v2DebugBonsplitUnderflowCount() -> V2CallResult { let resp = bonsplitUnderflowCount() guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) } let n = Int(resp.split(separator: " ").last ?? "0") ?? 0 return .ok(["count": n]) } - func v2DebugResetBonsplitUnderflowCount() -> V2CallResult { + nonisolated func v2DebugResetBonsplitUnderflowCount() -> V2CallResult { let resp = resetBonsplitUnderflowCount() return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } - func v2DebugEmptyPanelCount() -> V2CallResult { + nonisolated func v2DebugEmptyPanelCount() -> V2CallResult { let resp = emptyPanelCount() guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) } let n = Int(resp.split(separator: " ").last ?? "0") ?? 0 return .ok(["count": n]) } - func v2DebugResetEmptyPanelCount() -> V2CallResult { + nonisolated func v2DebugResetEmptyPanelCount() -> V2CallResult { let resp = resetEmptyPanelCount() return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } @@ -560,35 +537,36 @@ extension TerminalController { // is main-thread-confined (see ProgramaDurationSamples), so the actual read // genuinely requires the main-thread hop rather than being fired there out of // habit. - func v2DebugSamplesStats(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugSamplesStats(params: [String: Any]) -> V2CallResult { let requestedBucket = v2String(params, "bucket") - var result: [String: Any] = [:] - DispatchQueue.main.sync { + let result: [String: Any] = v2MainSync { + var result: [String: Any] = [:] if let requestedBucket { - if let payload = self.samplesStatsPayload(for: requestedBucket) { + if let payload = samplesStatsPayload(for: requestedBucket) { result[requestedBucket] = payload } } else { for name in ProgramaDurationSamples.shared.bucketNames() { - if let payload = self.samplesStatsPayload(for: name) { + if let payload = samplesStatsPayload(for: name) { result[name] = payload } } } + return result } return .ok(result) } - func v2DebugSamplesReset(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugSamplesReset(params: [String: Any]) -> V2CallResult { let requestedBucket = v2String(params, "bucket") - DispatchQueue.main.sync { + v2MainSync { ProgramaDurationSamples.shared.reset(bucket: requestedBucket) } return .ok([:]) } #endif - func v2DebugFocusNotification(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugFocusNotification(params: [String: Any]) -> V2CallResult { guard let wsId = v2String(params, "workspace_id") else { return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) } @@ -598,7 +576,7 @@ extension TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } - func v2DebugFlashCount(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugFlashCount(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) } @@ -608,12 +586,12 @@ extension TerminalController { return .ok(["count": n]) } - func v2DebugResetFlashCounts() -> V2CallResult { + nonisolated func v2DebugResetFlashCounts() -> V2CallResult { let resp = resetFlashCounts() return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } - func v2DebugPanelSnapshot(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugPanelSnapshot(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) } @@ -635,7 +613,7 @@ extension TerminalController { ]) } - func v2DebugPanelSnapshotReset(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugPanelSnapshotReset(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) } @@ -643,7 +621,7 @@ extension TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } - func v2DebugScreenshot(params: [String: Any]) -> V2CallResult { + nonisolated func v2DebugScreenshot(params: [String: Any]) -> V2CallResult { let label = v2String(params, "label") ?? "" let resp = captureScreenshot(label) guard resp.hasPrefix("OK ") else { @@ -668,15 +646,13 @@ extension TerminalController { return lines.suffix(maxLines).joined(separator: "\n") } - private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } - + private nonisolated func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines) - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { + return v2MainSync { + guard let tabManager = self.tabManager else { return "ERROR: TabManager not available" } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return + return "ERROR: No tab selected" } let panelId: UUID? @@ -688,17 +664,15 @@ extension TerminalController { guard let panelId, let terminalPanel = tab.terminalPanel(for: panelId) else { - result = "ERROR: Terminal surface not found" - return + return "ERROR: Terminal surface not found" } - result = readTerminalTextBase64( + return readTerminalTextBase64( terminalPanel: terminalPanel, includeScrollback: includeScrollback, lineLimit: lineLimit ) } - return result } #if DEBUG @@ -773,7 +747,7 @@ extension TerminalController { } } - private func simulateShortcut(_ args: String) -> String { + private nonisolated func simulateShortcut(_ args: String) -> String { let combo = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !combo.isEmpty else { return "ERROR: Usage: simulate_shortcut " @@ -786,8 +760,7 @@ extension TerminalController { // before the main-thread event dispatch. let requestTimestamp = ProcessInfo.processInfo.systemUptime - var result = "ERROR: Failed to create event" - DispatchQueue.main.sync { + return v2MainSync { // Prefer the current active-tab-manager window so shortcut simulation stays // scoped to the intended window even when NSApp.keyWindow is stale. let targetWindow: NSWindow? = { @@ -815,8 +788,7 @@ extension TerminalController { isARepeat: false, keyCode: parsed.keyCode ) else { - result = "ERROR: NSEvent.keyEvent returned nil" - return + return "ERROR: NSEvent.keyEvent returned nil" } let keyUpEvent = NSEvent.keyEvent( with: .keyUp, @@ -834,20 +806,18 @@ extension TerminalController { // app-level shortcut monitor (so tests are hermetic), while still falling back to the // normal responder chain for plain typing. if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) { - result = "OK" - return + return "OK" } NSApp.sendEvent(keyDownEvent) if let keyUpEvent { NSApp.sendEvent(keyUpEvent) } - result = "OK" + return "OK" } - return result } - private func activateApp() -> String { - v2MainSync { + private nonisolated func activateApp() -> String { + return v2MainSync { NSApp.activate(ignoringOtherApps: true) NSApp.unhide(nil) let hasMainTerminalWindow = NSApp.windows.contains { window in @@ -868,8 +838,8 @@ extension TerminalController { ?? NSApp.windows.first { window.makeKeyAndOrderFront(nil) } + return "OK" } - return "OK" } private func parseOverlayEventType(_ token: String) -> (isKnown: Bool, eventType: NSEvent.EventType?) { @@ -983,31 +953,26 @@ extension TerminalController { return out } - private func isTerminalFocused(_ args: String) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } - + private nonisolated func isTerminalFocused(_ args: String) -> String { let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !panelArg.isEmpty else { return "ERROR: Usage: is_terminal_focused " } - var result = "false" - DispatchQueue.main.sync { + return v2MainSync { + guard let tabManager = self.tabManager else { return "ERROR: TabManager not available" } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - result = "false" - return + return "false" } guard let panelId = resolveSurfaceId(from: panelArg, tab: tab), let terminalPanel = tab.terminalPanel(for: panelId) else { - result = "false" - return + return "false" } - result = terminalPanel.hostedView.isSurfaceViewFirstResponder() ? "true" : "false" + return terminalPanel.hostedView.isSurfaceViewFirstResponder() ? "true" : "false" } - return result } - private func readTerminalText(_ args: String) -> String { + private nonisolated func readTerminalText(_ args: String) -> String { readTerminalTextBase64(surfaceArg: args) } @@ -1030,16 +995,14 @@ extension TerminalController { let isFirstResponder: Bool } - private func renderStats(_ args: String) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } - + private nonisolated func renderStats(_ args: String) -> String { let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { + return v2MainSync { + guard let tabManager = self.tabManager else { return "ERROR: TabManager not available" } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return + return "ERROR: No tab selected" } let panelId: UUID? @@ -1051,8 +1014,7 @@ extension TerminalController { guard let panelId, let terminalPanel = tab.terminalPanel(for: panelId) else { - result = "ERROR: Terminal surface not found" - return + return "ERROR: Terminal surface not found" } let stats = terminalPanel.hostedView.debugRenderStats() @@ -1078,14 +1040,11 @@ extension TerminalController { let encoder = JSONEncoder() guard let data = try? encoder.encode(payload), let json = String(data: data, encoding: .utf8) else { - result = "ERROR: Failed to encode render_stats" - return + return "ERROR: Failed to encode render_stats" } - result = "OK \(json)" + return "OK \(json)" } - - return result } private struct ParsedShortcutCombo { @@ -1096,7 +1055,7 @@ extension TerminalController { let charactersIgnoringModifiers: String } - private func parseShortcutCombo(_ combo: String) -> ParsedShortcutCombo? { + private nonisolated func parseShortcutCombo(_ combo: String) -> ParsedShortcutCombo? { let raw = combo.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return nil } @@ -1195,7 +1154,7 @@ extension TerminalController { ) } - private func keyCodeForShortcutKey(_ key: String) -> UInt16? { + private nonisolated func keyCodeForShortcutKey(_ key: String) -> UInt16? { // Matches macOS ANSI key codes for common printable keys and a few named specials. switch key { case "a": return 0 // kVK_ANSI_A @@ -1252,58 +1211,51 @@ extension TerminalController { #endif #if DEBUG - private func focusFromNotification(_ args: String) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } + private nonisolated func focusFromNotification(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) let tabArg = parts.first ?? "" let surfaceArg = parts.count > 1 ? parts[1] : "" - var result = "OK" - v2MainSync { + return v2MainSync { + guard let tabManager = self.tabManager else { return "ERROR: TabManager not available" } guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else { - result = "ERROR: Tab not found" - return + return "ERROR: Tab not found" } let surfaceId = surfaceArg.isEmpty ? nil : resolveSurfaceId(from: surfaceArg, tab: tab) if !surfaceArg.isEmpty && surfaceId == nil { - result = "ERROR: Surface not found" - return + return "ERROR: Surface not found" } if !tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) { - result = "ERROR: Focus failed" + return "ERROR: Focus failed" } + return "OK" } - return result } - private func flashCount(_ args: String) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } + private nonisolated func flashCount(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" } - var result = "ERROR: Surface not found" - DispatchQueue.main.sync { + return v2MainSync { + guard let tabManager = self.tabManager else { return "ERROR: TabManager not available" } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - result = "ERROR: No tab selected" - return + return "ERROR: No tab selected" } guard let surfaceId = resolveSurfaceId(from: trimmed, tab: tab) else { - result = "ERROR: Surface not found" - return + return "ERROR: Surface not found" } let count = GhosttySurfaceScrollView.flashCount(for: surfaceId) - result = "OK \(count)" + return "OK \(count)" } - return result } - private func resetFlashCounts() -> String { - DispatchQueue.main.sync { + private nonisolated func resetFlashCounts() -> String { + return v2MainSync { GhosttySurfaceScrollView.resetFlashCounts() + return "OK" } - return "OK" } #if DEBUG @@ -1314,36 +1266,67 @@ extension TerminalController { let rgba: Data } + private enum PanelSnapshotCaptureOutcome: Sendable { + case captured(panelId: UUID, image: CGImage) + case failed(String) + } + + private enum PanelSnapshotResetOutcome: Sendable { + case resolved(UUID) + case failed(String) + } + + private enum ScreenshotCaptureOutcome: Sendable { + case captured(CGImage) + case failed(String) + } + /// Most tests run single-threaded but socket handlers can be invoked concurrently. /// Keep snapshot bookkeeping simple and thread-safe. - private static let panelSnapshotLock = NSLock() - private static var panelSnapshots: [UUID: PanelSnapshotState] = [:] + private nonisolated static let panelSnapshotLock = NSLock() + private nonisolated(unsafe) static var panelSnapshots: [UUID: PanelSnapshotState] = [:] + + static nonisolated func debugCaptureOutputURL(label: String, captureID: String) -> URL { + let outputDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-screenshots", isDirectory: true) + let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLabel.isEmpty else { + return outputDirectory.appendingPathComponent("\(captureID).png") + } + let safeLabel = DesignModeTextComposer.filenameSafeSelector(trimmedLabel) + return outputDirectory.appendingPathComponent("\(safeLabel)_\(captureID).png") + } - private func panelSnapshotReset(_ args: String) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } + private nonisolated func panelSnapshotReset(_ args: String) -> String { let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !panelArg.isEmpty else { return "ERROR: Usage: panel_snapshot_reset " } - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { + let outcome: PanelSnapshotResetOutcome = v2MainSync { + guard let tabManager = self.tabManager else { + return .failed("ERROR: TabManager not available") + } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return + return .failed("ERROR: No tab selected") } guard let panelId = resolveSurfaceId(from: panelArg, tab: tab) else { - result = "ERROR: Surface not found" - return + return .failed("ERROR: Surface not found") } + return .resolved(panelId) + } + + switch outcome { + case .failed(let error): + return error + case .resolved(let panelId): Self.panelSnapshotLock.lock() Self.panelSnapshots.removeValue(forKey: panelId) Self.panelSnapshotLock.unlock() - result = "OK" + return "OK" } - - return result } - private static func makePanelSnapshot(from cgImage: CGImage) -> PanelSnapshotState? { + private nonisolated static func makePanelSnapshot(from cgImage: CGImage) -> PanelSnapshotState? { let width = cgImage.width let height = cgImage.height guard width > 0, height > 0 else { return nil } @@ -1373,7 +1356,7 @@ extension TerminalController { return PanelSnapshotState(width: width, height: height, bytesPerRow: bytesPerRow, rgba: data) } - private static func countChangedPixels(previous: PanelSnapshotState, current: PanelSnapshotState) -> Int { + private nonisolated static func countChangedPixels(previous: PanelSnapshotState, current: PanelSnapshotState) -> Int { // Any mismatch means we can't sensibly diff; treat as a fresh snapshot. guard previous.width == current.width, previous.height == current.height, @@ -1409,8 +1392,7 @@ extension TerminalController { return changed } - private func panelSnapshot(_ args: String) -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } + private nonisolated func panelSnapshot(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "ERROR: Usage: panel_snapshot [label]" } @@ -1425,23 +1407,22 @@ extension TerminalController { let shortId = UUID().uuidString.prefix(8) let snapshotId = "\(timestamp)_\(shortId)" - let outputDir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-screenshots") + let outputPath = Self.debugCaptureOutputURL(label: label, captureID: snapshotId) + let outputDir = outputPath.deletingLastPathComponent() try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) - let filename = label.isEmpty ? "\(snapshotId).png" : "\(label)_\(snapshotId).png" - let outputPath = outputDir.appendingPathComponent(filename) - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { + let capture: PanelSnapshotCaptureOutcome = v2MainSync { + guard let tabManager = self.tabManager else { + return .failed("ERROR: TabManager not available") + } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return + return .failed("ERROR: No tab selected") } guard let panelId = resolveSurfaceId(from: panelArg, tab: tab), let terminalPanel = tab.terminalPanel(for: panelId) else { - result = "ERROR: Terminal surface not found" - return + return .failed("ERROR: Terminal surface not found") } // Capture the terminal's IOSurface directly, avoiding Screen Recording permissions. @@ -1453,41 +1434,47 @@ extension TerminalController { cgImage = view.debugCopyIOSurfaceCGImage() } guard let cgImage else { - result = "ERROR: Failed to capture panel image" - return + return .failed("ERROR: Failed to capture panel image") } + return .captured(panelId: panelId, image: cgImage) + } - guard let current = Self.makePanelSnapshot(from: cgImage) else { - result = "ERROR: Failed to read panel pixels" - return - } + let panelId: UUID + let cgImage: CGImage + switch capture { + case .failed(let error): + return error + case .captured(let capturedPanelId, let capturedImage): + panelId = capturedPanelId + cgImage = capturedImage + } - var changedPixels = -1 - Self.panelSnapshotLock.lock() - if let previous = Self.panelSnapshots[panelId] { - changedPixels = Self.countChangedPixels(previous: previous, current: current) - } - Self.panelSnapshots[panelId] = current - Self.panelSnapshotLock.unlock() + guard let current = Self.makePanelSnapshot(from: cgImage) else { + return "ERROR: Failed to read panel pixels" + } - // Save PNG for postmortem debugging. - let bitmap = NSBitmapImageRep(cgImage: cgImage) - guard let pngData = bitmap.representation(using: .png, properties: [:]) else { - result = "ERROR: Failed to encode PNG" - return - } + var changedPixels = -1 + Self.panelSnapshotLock.lock() + if let previous = Self.panelSnapshots[panelId] { + changedPixels = Self.countChangedPixels(previous: previous, current: current) + } + Self.panelSnapshots[panelId] = current + Self.panelSnapshotLock.unlock() - do { - try pngData.write(to: outputPath) - } catch { - result = "ERROR: Failed to write file: \(error.localizedDescription)" - return - } + // Save PNG for postmortem debugging. The baseline intentionally advances before + // encoding/writing so a failed diagnostic write still becomes the next comparison. + let bitmap = NSBitmapImageRep(cgImage: cgImage) + guard let pngData = bitmap.representation(using: .png, properties: [:]) else { + return "ERROR: Failed to encode PNG" + } - result = "OK \(panelId.uuidString) \(changedPixels) \(current.width) \(current.height) \(outputPath.path)" + do { + try pngData.write(to: outputPath) + } catch { + return "ERROR: Failed to write file: \(error.localizedDescription)" } - return result + return "OK \(panelId.uuidString) \(changedPixels) \(current.width) \(current.height) \(outputPath.path)" } #endif @@ -1519,14 +1506,12 @@ extension TerminalController { let keyWindowNumber: Int? } - private func layoutDebug() -> String { - guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } - - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { + private nonisolated func layoutDebug() -> String { + return v2MainSync { + guard let tabManager = self.tabManager else { return "ERROR: TabManager not available" } guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return + return "ERROR: No tab selected" } let layout = tab.bonsplitController.layoutSnapshot() @@ -1535,6 +1520,7 @@ extension TerminalController { paneFrames[pane.paneId] = pane.frame } + @MainActor func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { if view.isHidden { return true } var current = view.superview @@ -1545,6 +1531,7 @@ extension TerminalController { return false } + @MainActor func windowFrame(for view: NSView) -> CGRect? { guard view.window != nil else { return nil } // Prefer the view's frame as laid out by its superview. Some AppKit views @@ -1555,6 +1542,7 @@ extension TerminalController { return view.convert(view.bounds, to: nil) } + @MainActor func splitViewInfos(for view: NSView) -> [LayoutDebugSplitView] { var infos: [LayoutDebugSplitView] = [] var current: NSView? = view @@ -1684,52 +1672,46 @@ extension TerminalController { let encoder = JSONEncoder() guard let data = try? encoder.encode(payload), let json = String(data: data, encoding: .utf8) else { - result = "ERROR: Failed to encode layout_debug" - return + return "ERROR: Failed to encode layout_debug" } - result = "OK \(json)" + return "OK \(json)" } - return result } - private func emptyPanelCount() -> String { - var result = "OK 0" - DispatchQueue.main.sync { - result = "OK \(DebugUIEventCounters.emptyPanelAppearCount)" + private nonisolated func emptyPanelCount() -> String { + return v2MainSync { + "OK \(DebugUIEventCounters.emptyPanelAppearCount)" } - return result } - private func resetEmptyPanelCount() -> String { - DispatchQueue.main.sync { + private nonisolated func resetEmptyPanelCount() -> String { + return v2MainSync { DebugUIEventCounters.resetEmptyPanelAppearCount() + return "OK" } - return "OK" } - private func bonsplitUnderflowCount() -> String { - var result = "OK 0" - DispatchQueue.main.sync { + private nonisolated func bonsplitUnderflowCount() -> String { + return v2MainSync { #if DEBUG - result = "OK \(BonsplitDebugCounters.arrangedSubviewUnderflowCount)" + return "OK \(BonsplitDebugCounters.arrangedSubviewUnderflowCount)" #else - result = "OK 0" + return "OK 0" #endif } - return result } - private func resetBonsplitUnderflowCount() -> String { - DispatchQueue.main.sync { + private nonisolated func resetBonsplitUnderflowCount() -> String { + return v2MainSync { #if DEBUG BonsplitDebugCounters.reset() #endif + return "OK" } - return "OK" } - private func captureScreenshot(_ args: String) -> String { + private nonisolated func captureScreenshot(_ args: String) -> String { // Parse optional label from args let label = args.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1741,41 +1723,44 @@ extension TerminalController { let screenshotId = "\(timestamp)_\(shortId)" // Determine output path - let outputDir = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-screenshots") + let outputPath = Self.debugCaptureOutputURL(label: label, captureID: screenshotId) + let outputDir = outputPath.deletingLastPathComponent() try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) - let filename = label.isEmpty ? "\(screenshotId).png" : "\(label)_\(screenshotId).png" - let outputPath = outputDir.appendingPathComponent(filename) - - // Capture the main window on main thread - var captureError: String? - DispatchQueue.main.sync { + // Capture AppKit state on main, then encode and write the immutable image off-main. + let capture: ScreenshotCaptureOutcome = v2MainSync { guard let window = NSApp.mainWindow ?? NSApp.windows.first else { - captureError = "No window available" - return + return .failed("No window available") } guard let contentView = window.contentView, let bitmap = contentView.bitmapImageRepForCachingDisplay(in: contentView.bounds) else { - captureError = "Failed to prepare window image" - return + return .failed("Failed to prepare window image") } contentView.cacheDisplay(in: contentView.bounds, to: bitmap) - guard let pngData = bitmap.representation(using: .png, properties: [:]) else { - captureError = "Failed to create PNG data" - return - } - - do { - try pngData.write(to: outputPath) - } catch { - captureError = "Failed to write file: \(error.localizedDescription)" + guard let cgImage = bitmap.cgImage else { + return .failed("Failed to create PNG data") } + return .captured(cgImage) } - if let error = captureError { + let cgImage: CGImage + switch capture { + case .failed(let error): return "ERROR: \(error)" + case .captured(let image): + cgImage = image + } + + let bitmap = NSBitmapImageRep(cgImage: cgImage) + guard let pngData = bitmap.representation(using: .png, properties: [:]) else { + return "ERROR: Failed to create PNG data" + } + + do { + try pngData.write(to: outputPath) + } catch { + return "ERROR: Failed to write file: \(error.localizedDescription)" } // Return OK with screenshot ID and path for easy reference @@ -1922,7 +1907,7 @@ extension TerminalController { return nil } - func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { + nonisolated func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { switch raw.lowercased() { case "plain": return .plain diff --git a/Sources/TerminalController+Layout.swift b/Sources/TerminalController+Layout.swift index 5fc02e0d..ba3953bd 100644 --- a/Sources/TerminalController+Layout.swift +++ b/Sources/TerminalController+Layout.swift @@ -8,110 +8,93 @@ import Foundation extension TerminalController { // MARK: - V2 Layout Methods - func v2LayoutSave(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let name = v2String(params, "name") else { - return v2InvalidParam("name") - } + nonisolated func v2LayoutSave(params: [String: Any]) -> V2CallResult { + let name = v2String(params, "name") let force = v2Bool(params, "force") ?? false - let captureResult: Result = v2MainSync { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let name else { return v2InvalidParam("name") } guard let workspace = tabManager.selectedWorkspace else { - return .failure(.noActiveWorkspace) + return .err(code: "no_active_workspace", message: "No active workspace with capturable panes to save", data: nil) } guard let node = workspace.captureCustomLayout() else { - return .failure(.noActiveWorkspace) + return .err(code: "no_active_workspace", message: "No active workspace with capturable panes to save", data: nil) } - return .success(node) - } - let layoutNode: ProgramaLayoutNode - switch captureResult { - case .success(let node): - layoutNode = node - case .failure: - return .err(code: "no_active_workspace", message: "No active workspace with capturable panes to save", data: nil) - } - - let saveResult: Result = v2MainSync { - Result { try ProgramaLayoutStore.shared.save(name: name, layout: layoutNode, force: force) } - } - - switch saveResult { - case .success(let path): - return .ok(["name": name, "path": path]) - case .failure(let error): - switch error { - case ProgramaLayoutStoreError.alreadyExists: - return .err(code: "already_exists", message: "A layout named '\(name)' already exists", data: nil) - case ProgramaLayoutStoreError.invalidName: - return .err(code: "invalid_name", message: "Layout name must be non-empty and must not contain '/'", data: nil) - default: - return .err(code: "internal_error", message: String(describing: error), data: nil) + let saveResult: Result = Result { + try ProgramaLayoutStore.shared.save(name: name, layout: node, force: force) + } + switch saveResult { + case .success(let path): + return .ok(["name": name, "path": path]) + case .failure(let error): + switch error { + case ProgramaLayoutStoreError.alreadyExists: + return .err(code: "already_exists", message: "A layout named '\(name)' already exists", data: nil) + case ProgramaLayoutStoreError.invalidName: + return .err(code: "invalid_name", message: "Layout name must be non-empty and must not contain '/'", data: nil) + default: + return .err(code: "internal_error", message: String(describing: error), data: nil) + } } } } - func v2LayoutApply(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let name = v2String(params, "name") else { - return v2InvalidParam("name") - } - guard let saved = v2MainSync({ ProgramaLayoutStore.shared.load(name: name) }) else { - return .err(code: "not_found", message: "No saved layout named '\(name)'", data: nil) - } + nonisolated func v2LayoutApply(params: [String: Any]) -> V2CallResult { + let name = v2String(params, "name") let cwdParam = v2String(params, "cwd") + let workspaceId = v2UUID(params, "workspace_id") - if let workspaceId = v2UUID(params, "workspace_id") { - let applied: Bool = v2MainSync { - guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return false } - workspace.applyCustomLayout(saved.layout, baseCwd: cwdParam ?? workspace.currentDirectory) - return true + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) } - guard applied else { - return .err(code: "not_found", message: "Workspace not found", data: nil) + guard let name else { return v2InvalidParam("name") } + guard let saved = ProgramaLayoutStore.shared.load(name: name) else { + return .err(code: "not_found", message: "No saved layout named '\(name)'", data: nil) } + + if let workspaceId { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + workspace.applyCustomLayout(saved.layout, baseCwd: cwdParam ?? workspace.currentDirectory) + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + + // No target workspace given: create a new one (never focused -- layout.apply is a + // data operation, not a focus-intent v2 method). Relative `cwd`s in the saved layout + // resolve against this new workspace's own root, which is what makes + // `worktree create --layout`'s worktree-relative resolution work the same way. + let workspace = tabManager.addWorkspace(workingDirectory: cwdParam, select: false, eagerLoadTerminal: true) + workspace.applyCustomLayout(saved.layout, baseCwd: workspace.currentDirectory) + let newId = workspace.id let windowId = v2ResolveWindowId(tabManager: tabManager) return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "workspace_id": newId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: newId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - - // No target workspace given: create a new one (never focused -- layout.apply is a - // data operation, not a focus-intent v2 method). Relative `cwd`s in the saved layout - // resolve against this new workspace's own root, which is what makes - // `worktree create --layout`'s worktree-relative resolution work the same way. - var newId: UUID? - v2MainSync { - let workspace = tabManager.addWorkspace(workingDirectory: cwdParam, select: false, eagerLoadTerminal: true) - workspace.applyCustomLayout(saved.layout, baseCwd: workspace.currentDirectory) - newId = workspace.id - } - guard let newId else { - return .err(code: "internal_error", message: "Failed to create workspace", data: nil) - } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "workspace_id": newId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: newId), - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId) - ]) } - func v2LayoutList(params: [String: Any]) -> V2CallResult { + nonisolated func v2LayoutList(params: [String: Any]) -> V2CallResult { + let summaries = v2MainSync { + ProgramaLayoutStore.shared.list() + } let isoFormatter = ISO8601DateFormatter() - let layouts: [[String: Any]] = v2MainSync { - ProgramaLayoutStore.shared.list().map { summary in - ["name": summary.name, "saved_at": isoFormatter.string(from: summary.savedAt)] - } + let layouts: [[String: Any]] = summaries.map { summary in + ["name": summary.name, "saved_at": isoFormatter.string(from: summary.savedAt)] } return .ok(["layouts": layouts]) } diff --git a/Sources/TerminalController+Notification.swift b/Sources/TerminalController+Notification.swift index c98618a9..80ffc8ff 100644 --- a/Sources/TerminalController+Notification.swift +++ b/Sources/TerminalController+Notification.swift @@ -20,29 +20,25 @@ extension TerminalController { return "\(title) — \(custom)" } - func v2NotificationCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - + nonisolated func v2NotificationCreate(params: [String: Any]) -> V2CallResult { let explicitSurfaceId = v2UUID(params, "surface_id") let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" - var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) - v2MainSync { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } if let explicitSurfaceId, ws.panels[explicitSurfaceId] == nil { - result = .err( + return .err( code: "not_found", message: "Surface not found", data: ["surface_id": explicitSurfaceId.uuidString] ) - return } let surfaceId = explicitSurfaceId ?? ws.focusedPanelId let resolvedTitle = TerminalController.v2ResolveNotificationTitle(title: title, workspace: ws, surfaceId: surfaceId) @@ -53,32 +49,28 @@ extension TerminalController { subtitle: subtitle, body: body ) - result = .ok(["workspace_id": ws.id.uuidString, "surface_id": v2OrNull(surfaceId?.uuidString)]) + return .ok(["workspace_id": ws.id.uuidString, "surface_id": v2OrNull(surfaceId?.uuidString)]) } - return result } - func v2NotificationCreateForSurface(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - + nonisolated func v2NotificationCreateForSurface(params: [String: Any]) -> V2CallResult { + let surfaceId = v2UUID(params, "surface_id") let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" - var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) - v2MainSync { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceId else { + return v2InvalidParam("surface_id") + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } guard ws.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } let resolvedTitle = TerminalController.v2ResolveNotificationTitle(title: title, workspace: ws, surfaceId: surfaceId) TerminalNotificationStore.shared.addNotification( @@ -88,35 +80,32 @@ extension TerminalController { subtitle: subtitle, body: body ) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } - func v2NotificationCreateForTarget(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let wsId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - + nonisolated func v2NotificationCreateForTarget(params: [String: Any]) -> V2CallResult { + let wsId = v2UUID(params, "workspace_id") + let surfaceId = v2UUID(params, "surface_id") let title = (params["title"] as? String) ?? "Notification" let subtitle = (params["subtitle"] as? String) ?? "" let body = (params["body"] as? String) ?? "" - var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil) - v2MainSync { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let wsId else { + return v2InvalidParam("workspace_id") + } + guard let surfaceId else { + return v2InvalidParam("surface_id") + } guard let ws = tabManager.tabs.first(where: { $0.id == wsId }) else { - result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) - return + return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) } guard ws.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } let resolvedTitle = TerminalController.v2ResolveNotificationTitle(title: title, workspace: ws, surfaceId: surfaceId) TerminalNotificationStore.shared.addNotification( @@ -126,15 +115,13 @@ extension TerminalController { subtitle: subtitle, body: body ) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } - func v2NotificationList() -> [String: Any] { - var items: [[String: Any]] = [] - DispatchQueue.main.sync { - items = TerminalNotificationStore.shared.notifications.map { n in + nonisolated func v2NotificationList() -> [String: Any] { + let items: [[String: Any]] = v2MainSync { + TerminalNotificationStore.shared.notifications.map { n in return [ "id": n.id.uuidString, "workspace_id": n.tabId.uuidString, @@ -151,7 +138,7 @@ extension TerminalController { /// Mirrors v1's `clear_notifications [--tab=X]`: with a `workspace_id`, scopes the clear /// to that workspace's notifications only; without one, clears all notifications globally. - func v2NotificationClear(params: [String: Any]) -> V2CallResult { + nonisolated func v2NotificationClear(params: [String: Any]) -> V2CallResult { if let workspaceId = v2UUID(params, "workspace_id") { v2MainSync { TerminalNotificationStore.shared.clearNotifications(forTabId: workspaceId) diff --git a/Sources/TerminalController+Pane.swift b/Sources/TerminalController+Pane.swift index 0b7cb0f3..a76d33d9 100644 --- a/Sources/TerminalController+Pane.swift +++ b/Sources/TerminalController+Pane.swift @@ -8,14 +8,14 @@ import WebKit extension TerminalController { // MARK: - V2 Pane Methods - func v2PaneList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var payload: [String: Any]? + nonisolated func v2PaneList(params: [String: Any]) -> V2CallResult { v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let focusedPaneId = ws.bonsplitController.focusedPaneId let snapshot = ws.bonsplitController.layoutSnapshot() @@ -67,42 +67,33 @@ extension TerminalController { } let windowId = v2ResolveWindowId(tabManager: tabManager) - var payloadDict: [String: Any] = [ + var payload: [String: Any] = [ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "panes": panes, "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) ] - payloadDict["container_frame"] = [ + payload["container_frame"] = [ "width": snapshot.containerFrame.width, "height": snapshot.containerFrame.height ] - payload = payloadDict + return .ok(payload) } - - guard let payload else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - return .ok(payload) } - func v2PaneFocus(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let paneUUID = v2UUID(params, "pane_id") else { - return v2InvalidParam("pane_id") - } - - var result: V2CallResult = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) + nonisolated func v2PaneFocus(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let paneUUID = v2UUID(params, "pane_id") else { + return v2InvalidParam("pane_id") + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } guard let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else { - result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) - return + return .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) } if let windowId = v2ResolveWindowId(tabManager: tabManager) { _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) @@ -113,19 +104,18 @@ extension TerminalController { } ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) + return .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) } - return result } - func v2PaneSurfaces(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var payload: [String: Any]? + nonisolated func v2PaneSurfaces(params: [String: Any]) -> V2CallResult { v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Pane or workspace not found", data: nil) + } let paneUUID = v2UUID(params, "pane_id") let paneId: PaneID? = { @@ -134,7 +124,9 @@ extension TerminalController { } return ws.bonsplitController.focusedPaneId }() - guard let paneId else { return } + guard let paneId else { + return .err(code: "not_found", message: "Pane or workspace not found", data: nil) + } let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId) let tabs = ws.bonsplitController.tabs(inPane: paneId) @@ -153,7 +145,7 @@ extension TerminalController { } let windowId = v2ResolveWindowId(tabManager: tabManager) - payload = [ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, @@ -161,41 +153,33 @@ extension TerminalController { "surfaces": surfaces, "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) - ] - } - - guard let payload else { - return .err(code: "not_found", message: "Pane or workspace not found", data: nil) + ]) } - return .ok(payload) } - func v2PaneCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let directionStr = v2String(params, "direction"), - let direction = parseSplitDirection(directionStr) else { - return v2InvalidParam("direction (left|right|up|down)") - } + nonisolated func v2PaneCreate(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let directionStr = v2String(params, "direction"), + let direction = parseSplitDirection(directionStr) else { + return v2InvalidParam("direction (left|right|up|down)") + } - let panelType = v2PanelType(params, "type") ?? .terminal - let urlStr = v2String(params, "url") - let url = urlStr.flatMap { URL(string: $0) } + let panelType = v2PanelType(params, "type") ?? .terminal + let urlStr = v2String(params, "url") + let url = urlStr.flatMap { URL(string: $0) } - let orientation = direction.orientation - let insertFirst = direction.insertFirst + let orientation = direction.orientation + let insertFirst = direction.insertFirst - var result: V2CallResult = .err(code: "internal_error", message: "Failed to create pane", data: nil) - v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) guard let focusedPanelId = ws.focusedPanelId else { - result = .err(code: "not_found", message: "No focused surface to split", data: nil) - return + return .err(code: "not_found", message: "No focused surface to split", data: nil) } let newPanelId: UUID? @@ -217,12 +201,11 @@ extension TerminalController { } guard let newPanelId else { - result = .err(code: "internal_error", message: "Failed to create pane", data: nil) - return + return .err(code: "internal_error", message: "Failed to create pane", data: nil) } let paneUUID = ws.paneId(forPanelId: newPanelId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -234,7 +217,6 @@ extension TerminalController { "type": panelType.rawValue ]) } - return result } private enum V2PaneResizeDirection: String { @@ -331,40 +313,36 @@ extension TerminalController { } } - func v2PaneResize(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2PaneResize(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } - let directionRaw = (v2String(params, "direction") ?? "").lowercased() - let amount: Int - if v2HasNonNullParam(params, "amount") { - guard let parsedAmount = v2Int(params, "amount") else { - return .err(code: "invalid_params", message: "amount must be an integer", data: nil) + let directionRaw = (v2String(params, "direction") ?? "").lowercased() + let amount: Int + if v2HasNonNullParam(params, "amount") { + guard let parsedAmount = v2Int(params, "amount") else { + return .err(code: "invalid_params", message: "amount must be an integer", data: nil) + } + amount = parsedAmount + } else { + amount = 1 + } + guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else { + return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) } - amount = parsedAmount - } else { - amount = 1 - } - guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else { - return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) - } - var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil) - v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id guard let paneUUID else { - result = .err(code: "not_found", message: "No focused pane", data: nil) - return + return .err(code: "not_found", message: "No focused pane", data: nil) } guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { - result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) - return + return .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) } let tree = ws.bonsplitController.treeSnapshot() @@ -375,43 +353,39 @@ extension TerminalController { candidates: &candidates ) guard trace.containsTarget else { - result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString]) - return + return .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString]) } let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } guard !orientationMatches.isEmpty else { - result = .err( + return .err( code: "invalid_state", message: "No \(direction.splitOrientation) split ancestor for pane", data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] ) - return } guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else { - result = .err( + return .err( code: "invalid_state", message: "Pane has no adjacent border in direction \(direction.rawValue)", data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] ) - return } let delta = CGFloat(amount) / candidate.axisPixels let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) let clamped = min(max(requested, 0.1), 0.9) guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else { - result = .err( + return .err( code: "internal_error", message: "Failed to set split divider position", data: ["split_id": candidate.splitId.uuidString] ) - return } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -425,30 +399,26 @@ extension TerminalController { "new_divider_position": clamped ]) } - return result } - func v2PaneSwap(params: [String: Any]) -> V2CallResult { - guard let sourcePaneUUID = v2UUID(params, "pane_id") else { - return v2InvalidParam("pane_id") - } - guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { - return v2InvalidParam("target_pane_id") - } - if sourcePaneUUID == targetPaneUUID { - return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) - } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) + nonisolated func v2PaneSwap(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let sourcePaneUUID = v2UUID(params, "pane_id") else { + return v2InvalidParam("pane_id") + } + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return v2InvalidParam("target_pane_id") + } + if sourcePaneUUID == targetPaneUUID { + return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) + } + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + guard let located = v2LocatePane(sourcePaneUUID) else { - result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString]) - return + return .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString]) } guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else { - result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString]) - return + return .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString]) } let workspace = located.workspace let sourcePane = located.paneId @@ -457,8 +427,7 @@ extension TerminalController { let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane), let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id), let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else { - result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) - return + return .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) } // Keep pane identities stable during swap when one side has a single surface. @@ -467,25 +436,21 @@ extension TerminalController { if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 { sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id if sourcePlaceholder == nil { - result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) - return + return .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) } } if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id if targetPlaceholder == nil { - result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) - return + return .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) } } guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else { - result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil) - return + return .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil) } guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else { - result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil) - return + return .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil) } if let sourcePlaceholder { @@ -499,7 +464,7 @@ extension TerminalController { workspace.bonsplitController.focusPane(targetPane) } let windowId = located.windowId - result = .ok([ + return .ok([ "window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -514,20 +479,17 @@ extension TerminalController { "target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId) ]) } - return result } - func v2PaneBreak(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) + nonisolated func v2PaneBreak(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let sourcePaneUUID = v2UUID(params, "pane_id") @@ -547,50 +509,55 @@ extension TerminalController { return sourceWorkspace.focusedPanelId }() guard let surfaceId else { - result = .err(code: "not_found", message: "No source surface to break", data: nil) - return + return .err(code: "not_found", message: "No source surface to break", data: nil) } guard sourceWorkspace.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId) let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId) guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else { - result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil) - return + return .err(code: "internal_error", message: "Failed to detach source surface", data: nil) + } + let resolvedRollbackPane = sourcePaneForRollback.flatMap { pane in + sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? sourceWorkspace.bonsplitController.focusedPaneId + ?? sourceWorkspace.bonsplitController.allPaneIds.first + let rollbackTarget = resolvedRollbackPane.map { + Workspace.DetachedSurfaceAttachmentTarget( + workspace: sourceWorkspace, + paneId: $0, + index: sourceIndex, + focus: true + ) } let destinationWorkspace = tabManager.addWorkspace(select: focus) guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId ?? destinationWorkspace.bonsplitController.allPaneIds.first else { - if let sourcePaneForRollback { - _ = sourceWorkspace.attachDetachedSurface( - detached, - inPane: sourcePaneForRollback, - atIndex: sourceIndex, - focus: true - ) - } - result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) - return - } - - guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else { - if let sourcePaneForRollback { - _ = sourceWorkspace.attachDetachedSurface( - detached, - inPane: sourcePaneForRollback, - atIndex: sourceIndex, - focus: true - ) + if let rollbackTarget { + _ = detached.resolve(primary: rollbackTarget, rollback: nil) + } else { + detached.finalizePermanently() } - result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) - return + return .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) + } + + let attachmentResult = detached.resolve( + primary: Workspace.DetachedSurfaceAttachmentTarget( + workspace: destinationWorkspace, + paneId: destinationPane, + index: nil, + focus: focus + ), + rollback: rollbackTarget + ) + guard case .attachedPrimary = attachmentResult else { + return .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": destinationWorkspace.id.uuidString, @@ -601,63 +568,59 @@ extension TerminalController { "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) ]) } - return result } - func v2PaneJoin(params: [String: Any]) -> V2CallResult { - guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { - return v2InvalidParam("target_pane_id") - } - - var surfaceId = v2UUID(params, "surface_id") - if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") { - guard let sourceLocated = v2LocatePane(sourcePaneUUID), - let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), - let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { - return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [ - "pane_id": sourcePaneUUID.uuidString - ]) + nonisolated func v2PaneJoin(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return v2InvalidParam("target_pane_id") + } + + var surfaceId = v2UUID(params, "surface_id") + if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") { + guard let sourceLocated = v2LocatePane(sourcePaneUUID), + let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), + let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { + return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [ + "pane_id": sourcePaneUUID.uuidString + ]) + } + surfaceId = selectedSurface + } + guard let surfaceId else { + return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) } - surfaceId = selectedSurface - } - guard let surfaceId else { - return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) - } - var moveParams: [String: Any] = [ - "surface_id": surfaceId.uuidString, - "pane_id": targetPaneUUID.uuidString - ] - if let focus = v2Bool(params, "focus") { - moveParams["focus"] = focus + var moveParams: [String: Any] = [ + "surface_id": surfaceId.uuidString, + "pane_id": targetPaneUUID.uuidString + ] + if let focus = v2Bool(params, "focus") { + moveParams["focus"] = focus + } + return v2SurfaceMove(params: moveParams) } - return v2SurfaceMove(params: moveParams) } - func v2PaneLast(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil) + nonisolated func v2PaneLast(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } guard let focused = ws.bonsplitController.focusedPaneId else { - result = .err(code: "not_found", message: "No focused pane", data: nil) - return + return .err(code: "not_found", message: "No focused pane", data: nil) } guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else { - result = .err(code: "not_found", message: "No alternate pane available", data: nil) - return + return .err(code: "not_found", message: "No alternate pane available", data: nil) } ws.bonsplitController.focusPane(target) let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -668,6 +631,5 @@ extension TerminalController { "surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId) ]) } - return result } } diff --git a/Sources/TerminalController+Review.swift b/Sources/TerminalController+Review.swift index 02f7bbc4..661cce97 100644 --- a/Sources/TerminalController+Review.swift +++ b/Sources/TerminalController+Review.swift @@ -1,19 +1,130 @@ import Bonsplit import Foundation -// Agent diff review panel socket API (docs/plans/diff-review-panel.md §2). Mirrors -// `v2MarkdownOpen`'s (TerminalController+BrowserAutomation.swift) and `v2SurfaceSendText`'s -// (TerminalController+Surface.swift) exact patterns. +// Agent diff review panel socket API (docs/plans/diff-review-panel.md §2). // -// Threading: `review.open`/`review.refresh` compute the actual `git diff` snapshot -// (`ReviewDiffProber.diffSnapshot`) OUTSIDE any `v2MainSync` hop, on the calling (already -// off-main) socket-handling thread -- so the main thread is never blocked on git subprocess -// I/O, while the socket response can still report an accurate `diffable_file_count` because the -// snapshot is computed synchronously before the response is built. `review.comment.*` and -// `review.send_comments` are pure in-memory mutations on the review panel's own `@MainActor` -// state, so they still require a (fast, git-free) `v2MainSync` hop -- `ReviewPanel` is -// `@MainActor`-isolated like every other `Panel`. +// Threading: every handler orchestrates from the calling socket thread. Main-actor hops resolve +// or mutate UI-owned TabManager/Workspace/ReviewPanel state and return checked Sendable values; +// no UI-owned object crosses a hop. `review.open`/`review.refresh` run git subprocess I/O between +// those hops, so the main thread is never blocked on probing. Comment operations use one bounded, +// git-free mutation hop after pinning the exact window/workspace/panel IDs. + +private struct ReviewOpenContext: Sendable { + let windowId: UUID? + let workspaceId: UUID + let sourceSurfaceId: UUID + let sourcePaneId: UUID? + let directory: String + let mode: ReviewDiffMode + let baseBranch: String + let focusRequested: Bool + let horizontal: Bool + let insertFirst: Bool +} + +private enum ReviewOpenResolution: Sendable { + case tabManagerUnavailable + case invalidMode(String) + case workspaceNotFound + case noFocusedSurface + case sourceSurfaceNotFound(UUID) + case invalidDirection(String) + case ready(ReviewOpenContext) +} + +private struct ReviewOpenResult: Sendable { + let windowId: UUID? + let workspaceId: UUID + let paneId: UUID? + let surfaceId: UUID + let baseBranch: String +} + +private enum ReviewOpenCreation: Sendable { + case workspaceNotFound + case sourceSurfaceNotFound + case splitFailed + case created(ReviewOpenResult) +} + +private struct ReviewWorkspaceContext: Sendable { + let windowId: UUID? + let workspaceId: UUID +} + +private struct ReviewPanelContext: Sendable { + let workspace: ReviewWorkspaceContext + let panelId: UUID +} + +private struct ReviewRefreshContext: Sendable { + let target: ReviewPanelContext + let directory: String + let mode: ReviewDiffMode + let baseBranch: String +} + +private enum ReviewPanelResolution: Sendable { + case tabManagerUnavailable + case workspaceNotFound + case panelNotFound + case ready(Context) +} + +private enum ReviewPanelOperation: Sendable { + case workspaceNotFound + case panelNotFound + case value(Value) +} + +private enum ReviewCommentRemoveResult: Sendable { + case removed + case commentNotFound +} + +private enum ReviewCommentAddInput: Sendable { + case invalid(String) + case valid(filePath: String, startLine: Int, endLine: Int, text: String) +} + +private enum ReviewCommentRemoveInput: Sendable { + case invalid(String) + case valid(id: UUID, rawId: String) +} + +private enum ReviewCommentOperation: Sendable { + case tabManagerUnavailable + case workspaceNotFound + case panelNotFound + case value(Value) +} + +private enum ReviewValidatedCommentOperation: Sendable { + case tabManagerUnavailable + case invalidParams(String) + case workspaceNotFound + case panelNotFound + case value(Value) +} + +private struct ReviewCommentWireValue: Sendable { + let id: UUID + let filePath: String + let startLine: Int + let endLine: Int + let text: String + let createdAt: Int + let isStale: Bool +} + +private struct ReviewSendResult: Sendable { + let sentCount: Int + let sourceSurfaceId: UUID + let windowId: UUID? +} + extension TerminalController { + @MainActor private func v2ResolveReviewPanel(params: [String: Any], workspace ws: Workspace) -> ReviewPanel? { if let surfaceId = v2UUID(params, "surface_id") { return ws.reviewPanel(for: surfaceId) @@ -24,283 +135,433 @@ extension TerminalController { return ws.panels.values.compactMap { $0 as? ReviewPanel }.first } - func v2ReviewOpen(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + @MainActor + private func v2ReviewWorkspace(context: ReviewWorkspaceContext) -> (TabManager, Workspace)? { + let tabManager: TabManager? + if let windowId = context.windowId { + tabManager = AppDelegate.shared?.tabManagerFor(windowId: windowId) + } else { + tabManager = AppDelegate.shared?.tabManagerFor(tabId: context.workspaceId) + ?? self.tabManager } + guard let tabManager, + let workspace = tabManager.tabs.first(where: { $0.id == context.workspaceId }) else { + return nil + } + return (tabManager, workspace) + } + nonisolated func v2ReviewOpen(params: [String: Any]) -> V2CallResult { let modeRaw = v2String(params, "mode") ?? "uncommitted" - guard let mode = ReviewDiffMode(rawValue: modeRaw) else { - return .err(code: "invalid_params", message: "Invalid mode '\(modeRaw)' (uncommitted|branch)", data: nil) - } let baseBranch = v2String(params, "base_branch") ?? "origin/main" let focusRequested = v2Bool(params, "focus") ?? false - // Hop 1 (main actor): resolve workspace/source surface/directory/pane ids only -- no - // panel creation yet, so we can fail fast with `unavailable` before creating a split. - var resolveError: V2CallResult? - var sourceSurfaceId: UUID? - var sourcePaneUUID: UUID? - var directory: String? - var orientation: SplitOrientation? - var insertFirst = false - - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - resolveError = .err(code: "not_found", message: "Workspace not found", data: nil) - return + let resolution: ReviewOpenResolution = v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + return .tabManagerUnavailable } - let resolvedSourceSurfaceId = self.v2UUID(params, "surface_id") ?? ws.focusedPanelId - guard let resolvedSourceSurfaceId else { - resolveError = .err(code: "not_found", message: "No focused surface to review", data: nil) - return + guard let mode = ReviewDiffMode(rawValue: modeRaw) else { + return .invalidMode(modeRaw) } - guard ws.panels[resolvedSourceSurfaceId] != nil else { - resolveError = .err( - code: "not_found", - message: "Source surface not found", - data: ["surface_id": resolvedSourceSurfaceId.uuidString] - ) - return + guard let workspace = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .workspaceNotFound + } + let sourceSurfaceId = self.v2UUID(params, "surface_id") ?? workspace.focusedPanelId + guard let sourceSurfaceId else { + return .noFocusedSurface + } + guard workspace.panels[sourceSurfaceId] != nil else { + return .sourceSurfaceNotFound(sourceSurfaceId) } - let directionStr = self.v2String(params, "direction") ?? "right" - guard let direction = self.parseSplitDirection(directionStr) else { - resolveError = .err(code: "invalid_params", message: "Invalid direction '\(directionStr)' (left|right|up|down)", data: nil) - return + let directionRaw = self.v2String(params, "direction") ?? "right" + guard let direction = self.parseSplitDirection(directionRaw) else { + return .invalidDirection(directionRaw) } - sourceSurfaceId = resolvedSourceSurfaceId - sourcePaneUUID = ws.paneId(forPanelId: resolvedSourceSurfaceId)?.id - directory = ws.panelDirectories[resolvedSourceSurfaceId] ?? ws.currentDirectory - orientation = direction.isHorizontal ? .horizontal : .vertical - insertFirst = (direction == .left || direction == .up) - } - if let resolveError { return resolveError } - guard let sourceSurfaceId, let directory, let orientation else { - return .err(code: "internal_error", message: "Failed to resolve review target", data: nil) + return .ready(ReviewOpenContext( + windowId: AppDelegate.shared?.windowId(for: tabManager), + workspaceId: workspace.id, + sourceSurfaceId: sourceSurfaceId, + sourcePaneId: workspace.paneId(forPanelId: sourceSurfaceId)?.id, + directory: workspace.panelDirectories[sourceSurfaceId] ?? workspace.currentDirectory, + mode: mode, + baseBranch: baseBranch, + focusRequested: focusRequested, + horizontal: direction.isHorizontal, + insertFirst: direction == .left || direction == .up + )) } - // Fail fast if the resolved directory isn't inside a git worktree at all, before - // creating any split. - guard ReviewDiffProber.repositoryRoot(directory: directory) != nil else { - return .err(code: "unavailable", message: "Not a git repository: \(directory)", data: ["directory": directory]) + let context: ReviewOpenContext + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidMode(let raw): + return .err(code: "invalid_params", message: "Invalid mode '\(raw)' (uncommitted|branch)", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface to review", data: nil) + case .sourceSurfaceNotFound(let surfaceId): + return .err( + code: "not_found", + message: "Source surface not found", + data: ["surface_id": surfaceId.uuidString] + ) + case .invalidDirection(let raw): + return .err(code: "invalid_params", message: "Invalid direction '\(raw)' (left|right|up|down)", data: nil) + case .ready(let resolved): + context = resolved } - // Off-main: compute the first diff snapshot synchronously (see file header). - let snapshot = ReviewDiffProber.diffSnapshot(directory: directory, mode: mode, baseBranch: baseBranch) + // Off-main: the snapshot's repository lookup is also the pre-split git-repository gate, + // avoiding the previous duplicate `git rev-parse --show-toplevel` subprocess. + let snapshot = ReviewDiffProber.diffSnapshot( + directory: context.directory, + mode: context.mode, + baseBranch: context.baseBranch + ) + if snapshot.error == .notGitRepository { + return .err( + code: "unavailable", + message: "Not a git repository: \(context.directory)", + data: ["directory": context.directory] + ) + } - // Hop 2 (main actor): create the split + panel, apply the snapshot, build the response. - var result: V2CallResult = .err(code: "internal_error", message: "Failed to create review panel", data: nil) - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + let creation: ReviewOpenCreation = v2MainSync { + let target = ReviewWorkspaceContext( + windowId: context.windowId, + workspaceId: context.workspaceId + ) + guard let (tabManager, workspace) = self.v2ReviewWorkspace(context: target) else { + return .workspaceNotFound + } + guard workspace.panels[context.sourceSurfaceId] != nil else { + return .sourceSurfaceNotFound } - // Per the "Socket focus policy" (root CLAUDE.md) and docs/plans/diff-review-panel.md - // §2: `focus:false` (the default) must not activate the app or move focus at all. - if focusRequested { + // Per the socket focus policy, the default focus:false path does not activate the + // app or change workspace selection. The explicit focus path keeps its original + // focus-window, select-workspace, then create-and-focus ordering. + if context.focusRequested { self.v2MaybeFocusWindow(for: tabManager) - self.v2MaybeSelectWorkspace(tabManager, workspace: ws) + self.v2MaybeSelectWorkspace(tabManager, workspace: workspace) } - let created = ws.newReviewSplit( - from: sourceSurfaceId, + let orientation: SplitOrientation = context.horizontal ? .horizontal : .vertical + guard let created = workspace.newReviewSplit( + from: context.sourceSurfaceId, orientation: orientation, - insertFirst: insertFirst, - mode: mode, - baseBranch: baseBranch, - focus: self.v2FocusAllowed(requested: focusRequested) - ) - guard let created else { - result = .err(code: "internal_error", message: "Failed to create review panel", data: nil) - return + insertFirst: context.insertFirst, + mode: context.mode, + baseBranch: context.baseBranch, + focus: self.v2FocusAllowed(requested: context.focusRequested) + ) else { + return .splitFailed } created.apply(snapshot: snapshot) - let targetPaneUUID = ws.paneId(forPanelId: created.id)?.id - let windowId = self.v2ResolveWindowId(tabManager: tabManager) - result = .ok([ - "window_id": self.v2OrNull(windowId?.uuidString), - "window_ref": self.v2Ref(kind: .window, uuid: windowId), - "workspace_id": ws.id.uuidString, - "workspace_ref": self.v2Ref(kind: .workspace, uuid: ws.id), - "pane_id": self.v2OrNull(targetPaneUUID?.uuidString), - "pane_ref": self.v2Ref(kind: .pane, uuid: targetPaneUUID), - "surface_id": created.id.uuidString, - "surface_ref": self.v2Ref(kind: .surface, uuid: created.id), - "source_surface_id": sourceSurfaceId.uuidString, - "source_surface_ref": self.v2Ref(kind: .surface, uuid: sourceSurfaceId), - "source_pane_id": self.v2OrNull(sourcePaneUUID?.uuidString), - "source_pane_ref": self.v2Ref(kind: .pane, uuid: sourcePaneUUID), - "mode": mode.rawValue, + return .created(ReviewOpenResult( + windowId: AppDelegate.shared?.windowId(for: tabManager), + workspaceId: workspace.id, + paneId: workspace.paneId(forPanelId: created.id)?.id, + surfaceId: created.id, + baseBranch: created.baseBranch + )) + } + + switch creation { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .sourceSurfaceNotFound: + return .err( + code: "not_found", + message: "Source surface not found", + data: ["surface_id": context.sourceSurfaceId.uuidString] + ) + case .splitFailed: + return .err(code: "internal_error", message: "Failed to create review panel", data: nil) + case .created(let created): + return .ok([ + "window_id": v2OrNull(created.windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: created.windowId), + "workspace_id": created.workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: created.workspaceId), + "pane_id": v2OrNull(created.paneId?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: created.paneId), + "surface_id": created.surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: created.surfaceId), + "source_surface_id": context.sourceSurfaceId.uuidString, + "source_surface_ref": v2Ref(kind: .surface, uuid: context.sourceSurfaceId), + "source_pane_id": v2OrNull(context.sourcePaneId?.uuidString), + "source_pane_ref": v2Ref(kind: .pane, uuid: context.sourcePaneId), + "mode": context.mode.rawValue, "base_branch": created.baseBranch, "diffable_file_count": snapshot.diffableFileCount, "file_count": snapshot.files.count ]) } - return result } - func v2ReviewRefresh(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { + nonisolated func v2ReviewRefresh(params: [String: Any]) -> V2CallResult { + let resolution: ReviewPanelResolution = v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + return .tabManagerUnavailable + } + guard let workspace = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .workspaceNotFound + } + guard let panel = self.v2ResolveReviewPanel(params: params, workspace: workspace) else { + return .panelNotFound + } + return .ready(ReviewRefreshContext( + target: ReviewPanelContext( + workspace: ReviewWorkspaceContext( + windowId: AppDelegate.shared?.windowId(for: tabManager), + workspaceId: workspace.id + ), + panelId: panel.id + ), + directory: panel.directory, + mode: panel.mode, + baseBranch: panel.baseBranch + )) + } + + let context: ReviewRefreshContext + switch resolution { + case .tabManagerUnavailable: return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .panelNotFound: + return .err(code: "not_found", message: "Review panel not found", data: nil) + case .ready(let resolved): + context = resolved } - var resolveError: V2CallResult? - var reviewPanel: ReviewPanel? - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - resolveError = .err(code: "not_found", message: "Workspace not found", data: nil) - return + let snapshot = ReviewDiffProber.diffSnapshot( + directory: context.directory, + mode: context.mode, + baseBranch: context.baseBranch + ) + + let applied: ReviewPanelOperation = v2MainSync { + guard let (_, workspace) = self.v2ReviewWorkspace(context: context.target.workspace) else { + return .workspaceNotFound } - guard let panel = self.v2ResolveReviewPanel(params: params, workspace: ws) else { - resolveError = .err(code: "not_found", message: "Review panel not found", data: nil) - return + guard let panel = workspace.reviewPanel(for: context.target.panelId) else { + return .panelNotFound } - reviewPanel = panel - } - if let resolveError { return resolveError } - guard let reviewPanel else { - return .err(code: "internal_error", message: "Failed to resolve review panel", data: nil) + panel.apply(snapshot: snapshot) + return .value(()) } - // Off-main, mirroring `review.open` (see file header). - let snapshot = ReviewDiffProber.diffSnapshot(directory: reviewPanel.directory, mode: reviewPanel.mode, baseBranch: reviewPanel.baseBranch) - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to refresh review panel", data: nil) - v2MainSync { - reviewPanel.apply(snapshot: snapshot) - result = .ok([ + switch applied { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .panelNotFound: + return .err(code: "not_found", message: "Review panel not found", data: nil) + case .value: + return .ok([ "file_count": snapshot.files.count, "diffable_file_count": snapshot.diffableFileCount, "generated_at": Int(snapshot.generatedAt.timeIntervalSince1970) ]) } - return result } - func v2ReviewCommentAdd(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let filePath = v2String(params, "file_path"), !filePath.isEmpty else { - return .err(code: "invalid_params", message: "Missing file_path", data: nil) - } - guard let startLine = v2Int(params, "start_line"), startLine > 0 else { - return .err(code: "invalid_params", message: "Missing or invalid start_line", data: nil) - } - let endLine = v2Int(params, "end_line") ?? startLine - guard endLine >= startLine else { - return .err(code: "invalid_params", message: "end_line must be >= start_line", data: nil) - } - guard let text = v2String(params, "text"), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return .err(code: "invalid_params", message: "Missing text", data: nil) + nonisolated func v2ReviewCommentAdd(params: [String: Any]) -> V2CallResult { + let input: ReviewCommentAddInput + if let filePath = v2String(params, "file_path"), !filePath.isEmpty { + if let startLine = v2Int(params, "start_line"), startLine > 0 { + let endLine = v2Int(params, "end_line") ?? startLine + if endLine >= startLine { + if let text = v2String(params, "text"), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + input = .valid( + filePath: filePath, + startLine: startLine, + endLine: endLine, + text: text + ) + } else { + input = .invalid("Missing text") + } + } else { + input = .invalid("end_line must be >= start_line") + } + } else { + input = .invalid("Missing or invalid start_line") + } + } else { + input = .invalid("Missing file_path") } - var result: V2CallResult = .err(code: "not_found", message: "Review panel not found", data: nil) - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + let outcome: ReviewValidatedCommentOperation = v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + return .tabManagerUnavailable + } + guard case .valid(let filePath, let startLine, let endLine, let text) = input else { + guard case .invalid(let message) = input else { preconditionFailure() } + return .invalidParams(message) } - guard let reviewPanel = self.v2ResolveReviewPanel(params: params, workspace: ws) else { - result = .err(code: "not_found", message: "Review panel not found", data: nil) - return + guard let workspace = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .workspaceNotFound } - let comment = reviewPanel.addComment(filePath: filePath, startLine: startLine, endLine: endLine, text: text) - result = .ok(["comment_id": comment.id.uuidString]) + guard let panel = self.v2ResolveReviewPanel(params: params, workspace: workspace) else { + return .panelNotFound + } + return .value(panel.addComment( + filePath: filePath, + startLine: startLine, + endLine: endLine, + text: text + ).id) } - return result - } - func v2ReviewCommentRemove(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { + switch outcome { + case .tabManagerUnavailable: return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidParams(let message): + return .err(code: "invalid_params", message: message, data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .panelNotFound: + return .err(code: "not_found", message: "Review panel not found", data: nil) + case .value(let commentId): + return .ok(["comment_id": commentId.uuidString]) } - guard let commentIdString = v2String(params, "comment_id"), let commentId = UUID(uuidString: commentIdString) else { - return .err(code: "invalid_params", message: "Missing or invalid comment_id", data: nil) + } + + nonisolated func v2ReviewCommentRemove(params: [String: Any]) -> V2CallResult { + let input: ReviewCommentRemoveInput + if let rawId = v2String(params, "comment_id"), let id = UUID(uuidString: rawId) { + input = .valid(id: id, rawId: rawId) + } else { + input = .invalid("Missing or invalid comment_id") } - var result: V2CallResult = .err(code: "not_found", message: "Review panel not found", data: nil) - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + let outcome: ReviewValidatedCommentOperation<(ReviewCommentRemoveResult, String)> = v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + return .tabManagerUnavailable + } + guard case .valid(let commentId, let rawId) = input else { + guard case .invalid(let message) = input else { preconditionFailure() } + return .invalidParams(message) } - guard let reviewPanel = self.v2ResolveReviewPanel(params: params, workspace: ws) else { - result = .err(code: "not_found", message: "Review panel not found", data: nil) - return + guard let workspace = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .workspaceNotFound } - guard reviewPanel.removeComment(id: commentId) else { - result = .err(code: "not_found", message: "Comment not found", data: ["comment_id": commentIdString]) - return + guard let panel = self.v2ResolveReviewPanel(params: params, workspace: workspace) else { + return .panelNotFound } - result = .ok(["ok": true]) + let result: ReviewCommentRemoveResult = panel.removeComment(id: commentId) ? .removed : .commentNotFound + return .value((result, rawId)) } - return result - } - func v2ReviewCommentList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { + switch outcome { + case .tabManagerUnavailable: return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidParams(let message): + return .err(code: "invalid_params", message: message, data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .panelNotFound: + return .err(code: "not_found", message: "Review panel not found", data: nil) + case .value((.commentNotFound, let rawId)): + return .err(code: "not_found", message: "Comment not found", data: ["comment_id": rawId]) + case .value((.removed, _)): + return .ok(["ok": true]) } + } - var result: V2CallResult = .err(code: "not_found", message: "Review panel not found", data: nil) - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + nonisolated func v2ReviewCommentList(params: [String: Any]) -> V2CallResult { + let outcome: ReviewCommentOperation<[ReviewCommentWireValue]> = v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + return .tabManagerUnavailable + } + guard let workspace = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .workspaceNotFound } - guard let reviewPanel = self.v2ResolveReviewPanel(params: params, workspace: ws) else { - result = .err(code: "not_found", message: "Review panel not found", data: nil) - return + guard let panel = self.v2ResolveReviewPanel(params: params, workspace: workspace) else { + return .panelNotFound } - let comments: [[String: Any]] = reviewPanel.comments.map { comment in + return .value(panel.comments.map { comment in + ReviewCommentWireValue( + id: comment.id, + filePath: comment.filePath, + startLine: comment.startLine, + endLine: comment.endLine, + text: comment.text, + createdAt: Int(comment.createdAt.timeIntervalSince1970), + isStale: comment.isStale + ) + }) + } + + switch outcome { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .panelNotFound: + return .err(code: "not_found", message: "Review panel not found", data: nil) + case .value(let comments): + return .ok(["comments": comments.map { comment in [ "id": comment.id.uuidString, "file_path": comment.filePath, "start_line": comment.startLine, "end_line": comment.endLine, "text": comment.text, - "created_at": Int(comment.createdAt.timeIntervalSince1970), + "created_at": comment.createdAt, "is_stale": comment.isStale - ] - } - result = .ok(["comments": comments]) + ] as [String: Any] + }]) } - return result } - func v2ReviewSendComments(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2ReviewSendComments(params: [String: Any]) -> V2CallResult { let preamble = v2String(params, "preamble") - var result: V2CallResult = .err(code: "not_found", message: "Review panel not found", data: nil) - v2MainSync { - guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + let outcome: ReviewCommentOperation = v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { + return .tabManagerUnavailable + } + guard let workspace = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .workspaceNotFound } - guard let reviewPanel = self.v2ResolveReviewPanel(params: params, workspace: ws) else { - result = .err(code: "not_found", message: "Review panel not found", data: nil) - return + guard let panel = self.v2ResolveReviewPanel(params: params, workspace: workspace) else { + return .panelNotFound } - let sourceSurfaceId = reviewPanel.sourceSurfaceId - // Sending zero comments is a no-op, not a failure -- see docs/plans/diff-review-panel.md §2. - let sentCount = reviewPanel.sendPendingComments(preamble: preamble) - let windowId = self.v2ResolveWindowId(tabManager: tabManager) - result = .ok([ - "sent_count": sentCount, - "target_surface_id": sourceSurfaceId.uuidString, - "target_surface_ref": self.v2Ref(kind: .surface, uuid: sourceSurfaceId), - "window_id": self.v2OrNull(windowId?.uuidString), - "window_ref": self.v2Ref(kind: .window, uuid: windowId) + let sourceSurfaceId = panel.sourceSurfaceId + // Sending zero comments is a no-op, not a failure. + let sentCount = panel.sendPendingComments(preamble: preamble) + return .value(ReviewSendResult( + sentCount: sentCount, + sourceSurfaceId: sourceSurfaceId, + windowId: AppDelegate.shared?.windowId(for: tabManager) + )) + } + + switch outcome { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .panelNotFound: + return .err(code: "not_found", message: "Review panel not found", data: nil) + case .value(let sent): + return .ok([ + "sent_count": sent.sentCount, + "target_surface_id": sent.sourceSurfaceId.uuidString, + "target_surface_ref": v2Ref(kind: .surface, uuid: sent.sourceSurfaceId), + "window_id": v2OrNull(sent.windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: sent.windowId) ]) } - return result } } diff --git a/Sources/TerminalController+Snapshot.swift b/Sources/TerminalController+Snapshot.swift index 164616dc..242610a3 100644 --- a/Sources/TerminalController+Snapshot.swift +++ b/Sources/TerminalController+Snapshot.swift @@ -9,18 +9,23 @@ import Foundation extension TerminalController { // MARK: - V2 Snapshot Methods - func v2SnapshotList(params _: [String: Any]) -> V2CallResult { + nonisolated func v2SnapshotList(params _: [String: Any]) -> V2CallResult { + let isoFormatter = ISO8601DateFormatter() let payloads = SessionPersistenceStore.historyFileURLs().compactMap { url -> [String: Any]? in guard let data = try? Data(contentsOf: url), let snapshot = SessionPersistenceStore.decodeSnapshot(from: data) else { return nil } - return v2SnapshotListEntry(id: v2SnapshotHistoryId(for: url), snapshot: snapshot) + return v2SnapshotListEntry( + id: v2SnapshotHistoryId(for: url), + snapshot: snapshot, + isoFormatter: isoFormatter + ) } return .ok(["snapshots": payloads]) } - func v2SnapshotRestore(params: [String: Any]) -> V2CallResult { + nonisolated func v2SnapshotRestore(params: [String: Any]) -> V2CallResult { let requestedId = v2String(params, "id") let entries = SessionPersistenceStore.historyFileURLs() @@ -49,15 +54,14 @@ extension TerminalController { return .err(code: "not_found", message: "Archived snapshot has no windows to restore", data: nil) } - let windowsToRestore = SessionPersistenceStore.windowsToRestore(from: snapshot) - - var workspaceCount = 0 - var panelCount = 0 + let windowsToRestore: [SessionWindowSnapshot] = SessionPersistenceStore.windowsToRestore(from: snapshot) + let workspaceCount = windowsToRestore.reduce(0) { $0 + $1.tabManager.workspaces.count } + let panelCount = windowsToRestore.reduce(0) { total, window in + total + window.tabManager.workspaces.reduce(0) { $0 + $1.panels.count } + } v2MainSync { for windowSnapshot in windowsToRestore { _ = AppDelegate.shared?.createMainWindow(sessionWindowSnapshot: windowSnapshot) - workspaceCount += windowSnapshot.tabManager.workspaces.count - panelCount += windowSnapshot.tabManager.workspaces.reduce(0) { $0 + $1.panels.count } } } @@ -72,18 +76,22 @@ extension TerminalController { // MARK: - Shared helpers - private func v2SnapshotHistoryId(for url: URL) -> String { + private nonisolated func v2SnapshotHistoryId(for url: URL) -> String { url.deletingPathExtension().lastPathComponent } - private func v2SnapshotListEntry(id: String, snapshot: AppSessionSnapshot) -> [String: Any] { + private nonisolated func v2SnapshotListEntry( + id: String, + snapshot: AppSessionSnapshot, + isoFormatter: ISO8601DateFormatter + ) -> [String: Any] { let workspaceCount = snapshot.windows.reduce(0) { $0 + $1.tabManager.workspaces.count } let panelCount = snapshot.windows.reduce(0) { total, window in total + window.tabManager.workspaces.reduce(0) { $0 + $1.panels.count } } return [ "id": id, - "saved_at": ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: snapshot.createdAt)), + "saved_at": isoFormatter.string(from: Date(timeIntervalSince1970: snapshot.createdAt)), "created_at": snapshot.createdAt, "clean_shutdown": v2OrNull(snapshot.cleanShutdown), "window_count": snapshot.windows.count, diff --git a/Sources/TerminalController+Subscriptions.swift b/Sources/TerminalController+Subscriptions.swift index ce60f012..e27c28d3 100644 --- a/Sources/TerminalController+Subscriptions.swift +++ b/Sources/TerminalController+Subscriptions.swift @@ -41,17 +41,31 @@ final class SocketConnection: @unchecked Sendable { private let stateLock = NSLock() private var closed = false private(set) var subscription: EventSubscription? + private var pendingSubscription: EventSubscription? init(socket: Int32) { self.socket = socket } - /// Writes one newline-terminated line to the connection's socket. Used for both ordinary - /// v2 responses and pushed event frames -- the lock is what keeps them from interleaving. + /// Writes one newline-terminated ordinary v2 response to the connection's socket. Pushed + /// event frames use `writeEventLine`; both paths share the lock that prevents interleaving. /// Returns `false` on a write failure (client gone), at which point the caller should treat /// the connection as dead (the read loop will observe this on its next `read()` regardless). @discardableResult func writeLine(_ line: String) -> Bool { + writeLine(line, activatesPendingSubscription: true) + } + + /// Event frames share the response write lock but must never activate a subscription that + /// is waiting for its subscribe acknowledgment. An old subscription can still have a frame + /// in flight while it is being replaced, so treating every successful write as an + /// acknowledgment would let that old frame activate the replacement too early. + @discardableResult + func writeEventLine(_ line: String) -> Bool { + writeLine(line, activatesPendingSubscription: false) + } + + private func writeLine(_ line: String, activatesPendingSubscription: Bool) -> Bool { let bytes = Array((line + "\n").utf8) writeLock.lock() defer { writeLock.unlock() } @@ -66,30 +80,72 @@ final class SocketConnection: @unchecked Sendable { write(socket, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) } if written <= 0 { + discardPendingSubscription(reason: "response_write_failure") return false } offset += written } + if activatesPendingSubscription { + activatePendingSubscription() + } return true } + private func activatePendingSubscription() { + stateLock.lock() + let pending = pendingSubscription + pendingSubscription = nil + if let pending { + subscription = pending + } + stateLock.unlock() + pending?.activate() + } + + private func discardPendingSubscription(reason: String) { + stateLock.lock() + let pending = pendingSubscription + pendingSubscription = nil + stateLock.unlock() + pending?.teardown(reason: reason) + } + /// Attaches a subscription to this connection, tearing down any previous one first -- /// `subscribe` replaces an existing subscription rather than stacking a second one (a /// connection has at most one). func attach(_ subscription: EventSubscription) { stateLock.lock() - let previous = self.subscription - self.subscription = subscription + guard !closed else { + stateLock.unlock() + subscription.teardown(reason: "connection_closed") + return + } + let previousActive = self.subscription + let previousPending = pendingSubscription + self.subscription = nil + pendingSubscription = subscription + SocketEventBroadcaster.shared.replace( + previous: [previousActive, previousPending].compactMap { $0 }, + with: subscription + ) stateLock.unlock() - previous?.teardown(reason: "replaced_by_new_subscription") + previousActive?.teardown(reason: "replaced_by_new_subscription") + if let previousPending, previousPending !== previousActive { + previousPending.teardown(reason: "replaced_by_new_subscription") + } } func detachSubscription() { stateLock.lock() - let previous = subscription + let previousActive = subscription + let previousPending = pendingSubscription subscription = nil + pendingSubscription = nil stateLock.unlock() - previous?.teardown(reason: "unsubscribed") + previousActive?.teardown(reason: "unsubscribed") + if let previousPending, previousPending !== previousActive { + previousPending.teardown(reason: "unsubscribed") + } } /// Called from `handleClient`'s `defer`, in addition to (and before) closing the raw fd. @@ -97,14 +153,19 @@ final class SocketConnection: @unchecked Sendable { writeLock.lock() stateLock.lock() closed = true - let previous = subscription + let previousActive = subscription + let previousPending = pendingSubscription subscription = nil + pendingSubscription = nil stateLock.unlock() writeLock.unlock() - if previous != nil { + if previousActive != nil || previousPending != nil { dilog("socket.conn", "teardown hadSubscription=true") } - previous?.teardown(reason: "connection_closed") + previousActive?.teardown(reason: "connection_closed") + if let previousPending, previousPending !== previousActive { + previousPending.teardown(reason: "connection_closed") + } } } @@ -132,6 +193,7 @@ final class EventSubscription: @unchecked Sendable { private var pending: [[String: Any]] = [] private var droppedCount = 0 private var isDraining = false + private var isActivated = false private var isTornDown = false init(connection: SocketConnection, classes: Set, outputSurfaceIds: Set) { @@ -152,7 +214,23 @@ final class EventSubscription: @unchecked Sendable { droppedCount += 1 } pending.append(frame) - let shouldSchedule = !isDraining + let shouldSchedule = isActivated && !isDraining + if shouldSchedule { isDraining = true } + lock.unlock() + + if shouldSchedule { + drainQueue.async { [weak self] in self?.drain() } + } + } + + /// Enables delivery after the subscribe acknowledgment has been written. The connection + /// keeps its write lock held while calling this, so a newly scheduled drain cannot overtake + /// those response bytes on the socket. + func activate() { + lock.lock() + guard !isTornDown, !isActivated else { lock.unlock(); return } + isActivated = true + let shouldSchedule = !isDraining && (droppedCount > 0 || !pending.isEmpty) if shouldSchedule { isDraining = true } lock.unlock() @@ -169,6 +247,11 @@ final class EventSubscription: @unchecked Sendable { lock.unlock() return } + if !isActivated { + isDraining = false + lock.unlock() + return + } if droppedCount > 0 { let count = droppedCount droppedCount = 0 @@ -198,7 +281,7 @@ final class EventSubscription: @unchecked Sendable { lock.unlock() guard shouldDeliver else { return true } guard let connection else { return false } - return connection.writeLine(line) + return connection.writeEventLine(line) } /// Idempotent. Called on write failure (client gone), on `unsubscribe`, and from @@ -233,9 +316,16 @@ final class SocketEventBroadcaster: @unchecked Sendable { /// tail even when the viewport scrolls and its overall length stays constant. private var lastOutputText: [UUID: String] = [:] - func register(_ subscription: EventSubscription) { + /// Atomically swaps the connection's old subscription entries for its pending replacement. + /// Publishers can snapshot either generation, but never both from the broadcaster registry. + func replace(previous: [EventSubscription], with subscription: EventSubscription) { lock.lock() + for old in previous { + subscriptions.removeValue(forKey: old.id) + } subscriptions[subscription.id] = subscription + let stillWatched = Set(subscriptions.values.flatMap { $0.outputSurfaceIds }) + lastOutputText = lastOutputText.filter { stillWatched.contains($0.key) } lock.unlock() } @@ -356,7 +446,7 @@ extension TerminalController { /// `subscribe`: upgrades the calling connection to receive pushed events for the requested /// `classes` (any of `agent_state`, `output`, `workspace_lifecycle`). Replaces any existing /// subscription on this connection. `output` requires a non-empty `surface_ids` array. - func v2Subscribe(params: [String: Any], connection: SocketConnection) -> V2CallResult { + nonisolated func v2Subscribe(params: [String: Any], connection: SocketConnection) -> V2CallResult { guard let rawClasses = v2StringArray(params, "classes"), !rawClasses.isEmpty else { return .err( code: "invalid_params", @@ -394,7 +484,6 @@ extension TerminalController { } let subscription = EventSubscription(connection: connection, classes: classes, outputSurfaceIds: outputSurfaceIds) - SocketEventBroadcaster.shared.register(subscription) connection.attach(subscription) if classes.contains(.output) { v2StartOutputPollLoopIfNeeded() @@ -410,15 +499,15 @@ extension TerminalController { /// `unsubscribe`: tears down any live subscription on the calling connection. No-ops (still /// `ok`) if there wasn't one, so a client doesn't need to track whether it subscribed. - func v2Unsubscribe(params: [String: Any], connection: SocketConnection) -> V2CallResult { + nonisolated func v2Unsubscribe(params: [String: Any], connection: SocketConnection) -> V2CallResult { connection.detachSubscription() return .ok(["unsubscribed": true]) } // MARK: - Output event polling (#167 task 3) - private static let outputPollInterval: TimeInterval = 0.1 - private static let outputPollLock = NSLock() + private nonisolated static let outputPollInterval: TimeInterval = 0.1 + private nonisolated static let outputPollLock = NSLock() private nonisolated(unsafe) static var outputPollTimer: DispatchSourceTimer? private nonisolated(unsafe) static var outputPollGeneration: UInt64 = 0 @@ -430,7 +519,7 @@ extension TerminalController { /// of reusing a single point-in-time text read rather than a push-based content-changed /// callback (Ghostty doesn't expose one at the app layer). The timer cancels itself when /// the final output subscriber disconnects and restarts on a later subscription. - func v2StartOutputPollLoopIfNeeded() { + nonisolated func v2StartOutputPollLoopIfNeeded() { Self.outputPollLock.lock() defer { Self.outputPollLock.unlock() } guard Self.outputPollTimer == nil else { return } @@ -450,7 +539,7 @@ extension TerminalController { timer.resume() } - private func v2PollSubscribedOutputOnce(generation: UInt64) { + private nonisolated func v2PollSubscribedOutputOnce(generation: UInt64) { Self.outputPollLock.lock() let isCurrentPoll = Self.outputPollGeneration == generation && Self.outputPollTimer != nil Self.outputPollLock.unlock() @@ -484,7 +573,7 @@ extension TerminalController { } } - private func v2StopOutputPollIfIdle(generation: UInt64) { + private nonisolated func v2StopOutputPollIfIdle(generation: UInt64) { Self.outputPollLock.lock() defer { Self.outputPollLock.unlock() } guard Self.outputPollGeneration == generation, diff --git a/Sources/TerminalController+Surface.swift b/Sources/TerminalController+Surface.swift index b13efd0e..002c0f07 100644 --- a/Sources/TerminalController+Surface.swift +++ b/Sources/TerminalController+Surface.swift @@ -6,14 +6,14 @@ import Bonsplit import WebKit extension TerminalController { - func v2SurfaceList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var payload: [String: Any]? + nonisolated func v2SurfaceList(params: [String: Any]) -> V2CallResult { v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } // Map panel_id -> pane_id and index/selection within that pane. var paneByPanelId: [UUID: UUID] = [:] @@ -54,31 +54,26 @@ extension TerminalController { return item } - payload = [ + var payload: [String: Any] = [ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surfaces": surfaces ] + let windowId = v2ResolveWindowId(tabManager: tabManager) + payload["window_id"] = v2OrNull(windowId?.uuidString) + payload["window_ref"] = v2Ref(kind: .window, uuid: windowId) + return .ok(payload) } - - guard let payload else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - var out = payload - let windowId = v2ResolveWindowId(tabManager: tabManager) - out["window_id"] = v2OrNull(windowId?.uuidString) - out["window_ref"] = v2Ref(kind: .window, uuid: windowId) - return .ok(out) } - func v2SurfaceCurrent(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var payload: [String: Any]? + nonisolated func v2SurfaceCurrent(params: [String: Any]) -> V2CallResult { v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } // Focus can be transiently nil during startup/reparenting; fall back to first // ordered panel so callers always get a usable current surface. @@ -86,7 +81,7 @@ extension TerminalController { let paneId = surfaceId.flatMap { ws.paneId(forPanelId: $0)?.id } let windowId = v2ResolveWindowId(tabManager: tabManager) - payload = [ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -96,28 +91,20 @@ extension TerminalController { "surface_id": v2OrNull(surfaceId?.uuidString), "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "surface_type": v2OrNull(surfaceId.flatMap { ws.panels[$0]?.panelType.rawValue }) - ] - } - - guard let payload else { - return .err(code: "not_found", message: "Workspace not found", data: nil) + ]) } - return .ok(payload) } - func v2SurfaceFocus(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - - var result: V2CallResult = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) + nonisolated func v2SurfaceFocus(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return v2InvalidParam("surface_id") + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } if let windowId = v2ResolveWindowId(tabManager: tabManager) { @@ -131,37 +118,31 @@ extension TerminalController { } guard ws.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } ws.focusPanel(surfaceId) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } - func v2SurfaceSplit(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let directionStr = v2String(params, "direction"), - let direction = parseSplitDirection(directionStr) else { - return v2InvalidParam("direction (left|right|up|down)") - } - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to create split", data: nil) + nonisolated func v2SurfaceSplit(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let directionStr = v2String(params, "direction"), + let direction = parseSplitDirection(directionStr) else { + return v2InvalidParam("direction (left|right|up|down)") + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let requestedSurfaceId: UUID? = v2UUID(params, "surface_id") // Fall back to focused surface if the requested surface no longer exists (e.g. closed teammate pane) let targetSurfaceId: UUID? = requestedSurfaceId.flatMap({ ws.panels[$0] != nil ? $0 : nil }) ?? ws.focusedPanelId guard let targetSurfaceId, ws.panels[targetSurfaceId] != nil else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } v2MaybeFocusWindow(for: tabManager) @@ -171,7 +152,7 @@ extension TerminalController { if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction, focus: focus) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -183,25 +164,22 @@ extension TerminalController { "type": v2OrNull(ws.panels[newId]?.panelType.rawValue) ]) } else { - result = .err(code: "internal_error", message: "Failed to create split", data: nil) + return .err(code: "internal_error", message: "Failed to create split", data: nil) } } - return result } - func v2SurfaceCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2SurfaceCreate(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } - let panelType = v2PanelType(params, "type") ?? .terminal - let urlStr = v2String(params, "url") - let url = urlStr.flatMap { URL(string: $0) } + let panelType = v2PanelType(params, "type") ?? .terminal + let urlStr = v2String(params, "url") + let url = urlStr.flatMap { URL(string: $0) } - var result: V2CallResult = .err(code: "internal_error", message: "Failed to create surface", data: nil) - v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) @@ -215,8 +193,7 @@ extension TerminalController { }() guard let paneId else { - result = .err(code: "not_found", message: "Pane not found", data: nil) - return + return .err(code: "not_found", message: "Pane not found", data: nil) } let newPanelId: UUID? @@ -227,12 +204,11 @@ extension TerminalController { } guard let newPanelId else { - result = .err(code: "internal_error", message: "Failed to create surface", data: nil) - return + return .err(code: "internal_error", message: "Failed to create surface", data: nil) } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -244,35 +220,28 @@ extension TerminalController { "type": panelType.rawValue ]) } - return result } - func v2SurfaceClose(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to close surface", data: nil) + nonisolated func v2SurfaceClose(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } guard ws.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } if ws.panels.count <= 1 { - result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil) - return + return .err(code: "invalid_state", message: "Cannot close the last surface", data: nil) } // Socket API must be non-interactive: bypass close-confirmation gating. Terminal @@ -285,46 +254,41 @@ extension TerminalController { } else { _ = ws.closePanel(surfaceId, force: true) } - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } - func v2SurfaceDragToSplit(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } - guard let directionStr = v2String(params, "direction"), - let direction = parseSplitDirection(directionStr) else { - return v2InvalidParam("direction (left|right|up|down)") - } + nonisolated func v2SurfaceDragToSplit(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return v2InvalidParam("surface_id") + } + guard let directionStr = v2String(params, "direction"), + let direction = parseSplitDirection(directionStr) else { + return v2InvalidParam("direction (left|right|up|down)") + } - let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical - let insertFirst = (direction == .left || direction == .up) + let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical + let insertFirst = (direction == .left || direction == .up) - var result: V2CallResult = .err(code: "internal_error", message: "Failed to move surface", data: nil) - v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } guard let bonsplitTabId = ws.surfaceIdFromPanelId(surfaceId) else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } guard let newPaneId = ws.bonsplitController.splitPane( orientation: orientation, movingTab: bonsplitTabId, insertFirst: insertFirst ) else { - result = .err(code: "internal_error", message: "Failed to split pane", data: nil) - return + return .err(code: "internal_error", message: "Failed to split pane", data: nil) } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, @@ -335,38 +299,34 @@ extension TerminalController { "pane_ref": v2Ref(kind: .pane, uuid: newPaneId.id) ]) } - return result } - func v2SurfaceMove(params: [String: Any]) -> V2CallResult { - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } + nonisolated func v2SurfaceMove(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let surfaceId = v2UUID(params, "surface_id") else { + return v2InvalidParam("surface_id") + } - let requestedPaneUUID = v2UUID(params, "pane_id") - let requestedWorkspaceUUID = v2UUID(params, "workspace_id") - let requestedWindowUUID = v2UUID(params, "window_id") - let beforeSurfaceId = v2UUID(params, "before_surface_id") - let afterSurfaceId = v2UUID(params, "after_surface_id") - let explicitIndex = v2Int(params, "index") - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - - let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) - if anchorCount > 1 { - return .err(code: "invalid_params", message: "Specify at most one of before_surface_id or after_surface_id", data: nil) - } + let requestedPaneUUID = v2UUID(params, "pane_id") + let requestedWorkspaceUUID = v2UUID(params, "workspace_id") + let requestedWindowUUID = v2UUID(params, "window_id") + let beforeSurfaceId = v2UUID(params, "before_surface_id") + let afterSurfaceId = v2UUID(params, "after_surface_id") + let explicitIndex = v2Int(params, "index") + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) + + let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) + if anchorCount > 1 { + return .err(code: "invalid_params", message: "Specify at most one of before_surface_id or after_surface_id", data: nil) + } - var result: V2CallResult = .err(code: "internal_error", message: "Failed to move surface", data: nil) - v2MainSync { guard let app = AppDelegate.shared else { - result = .err(code: "unavailable", message: "AppDelegate not available", data: nil) - return + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) } guard let source = app.locateSurface(surfaceId: surfaceId), let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } let sourcePane = sourceWorkspace.paneId(forPanelId: surfaceId) @@ -383,8 +343,7 @@ extension TerminalController { let anchorWorkspace = anchor.tabManager.tabs.first(where: { $0.id == anchor.workspaceId }), let anchorPane = anchorWorkspace.paneId(forPanelId: anchorSurfaceId), let anchorIndex = anchorWorkspace.indexInPane(forPanelId: anchorSurfaceId) else { - result = .err(code: "not_found", message: "Anchor surface not found", data: ["surface_id": anchorSurfaceId.uuidString]) - return + return .err(code: "not_found", message: "Anchor surface not found", data: ["surface_id": anchorSurfaceId.uuidString]) } targetWindowId = anchor.windowId targetTabManager = anchor.tabManager @@ -393,8 +352,7 @@ extension TerminalController { targetIndex = (beforeSurfaceId != nil) ? anchorIndex : (anchorIndex + 1) } else if let paneUUID = requestedPaneUUID { guard let located = v2LocatePane(paneUUID) else { - result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) - return + return .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) } targetWindowId = located.windowId targetTabManager = located.tabManager @@ -403,8 +361,7 @@ extension TerminalController { } else if let workspaceUUID = requestedWorkspaceUUID { guard let tm = app.tabManagerFor(tabId: workspaceUUID), let ws = tm.tabs.first(where: { $0.id == workspaceUUID }) else { - result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceUUID.uuidString]) - return + return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceUUID.uuidString]) } targetTabManager = tm targetWorkspace = ws @@ -412,31 +369,27 @@ extension TerminalController { targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first } else if let windowUUID = requestedWindowUUID { guard let tm = app.tabManagerFor(windowId: windowUUID) else { - result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowUUID.uuidString]) - return + return .err(code: "not_found", message: "Window not found", data: ["window_id": windowUUID.uuidString]) } targetWindowId = windowUUID targetTabManager = tm guard let selectedWorkspaceId = tm.selectedTabId, let ws = tm.tabs.first(where: { $0.id == selectedWorkspaceId }) else { - result = .err(code: "not_found", message: "Target window has no selected workspace", data: ["window_id": windowUUID.uuidString]) - return + return .err(code: "not_found", message: "Target window has no selected workspace", data: ["window_id": windowUUID.uuidString]) } targetWorkspace = ws targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first } guard let destinationPane = targetPane else { - result = .err(code: "not_found", message: "No destination pane", data: nil) - return + return .err(code: "not_found", message: "No destination pane", data: nil) } if targetWorkspace.id == sourceWorkspace.id { guard sourceWorkspace.moveSurface(panelId: surfaceId, toPane: destinationPane, atIndex: targetIndex, focus: focus) else { - result = .err(code: "internal_error", message: "Failed to move surface", data: nil) - return + return .err(code: "internal_error", message: "Failed to move surface", data: nil) } - result = .ok([ + return .ok([ "window_id": targetWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: targetWindowId), "workspace_id": targetWorkspace.id.uuidString, @@ -446,24 +399,34 @@ extension TerminalController { "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) ]) - return } guard let transfer = sourceWorkspace.detachSurface(panelId: surfaceId) else { - result = .err(code: "internal_error", message: "Failed to detach surface", data: nil) - return - } - - if targetWorkspace.attachDetachedSurface(transfer, inPane: destinationPane, atIndex: targetIndex, focus: focus) == nil { - // Roll back to source workspace if attach fails. - let rollbackPane = sourcePane.flatMap { sp in sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0 == sp }) } - ?? sourceWorkspace.bonsplitController.focusedPaneId - ?? sourceWorkspace.bonsplitController.allPaneIds.first - if let rollbackPane { - _ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus) - } - result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) - return + return .err(code: "internal_error", message: "Failed to detach surface", data: nil) + } + + let rollbackPane = sourcePane.flatMap { sp in sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0 == sp }) } + ?? sourceWorkspace.bonsplitController.focusedPaneId + ?? sourceWorkspace.bonsplitController.allPaneIds.first + let rollbackTarget = rollbackPane.map { + Workspace.DetachedSurfaceAttachmentTarget( + workspace: sourceWorkspace, + paneId: $0, + index: sourceIndex, + focus: focus + ) + } + let attachmentResult = transfer.resolve( + primary: Workspace.DetachedSurfaceAttachmentTarget( + workspace: targetWorkspace, + paneId: destinationPane, + index: targetIndex, + focus: focus + ), + rollback: rollbackTarget + ) + guard case .attachedPrimary = attachmentResult else { + return .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil) } if focus { @@ -472,7 +435,7 @@ extension TerminalController { targetTabManager.selectWorkspace(targetWorkspace) } - result = .ok([ + return .ok([ "window_id": targetWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: targetWindowId), "workspace_id": targetWorkspace.id.uuidString, @@ -483,31 +446,27 @@ extension TerminalController { "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) ]) } - - return result } - func v2SurfaceReorder(params: [String: Any]) -> V2CallResult { - guard let surfaceId = v2UUID(params, "surface_id") else { - return v2InvalidParam("surface_id") - } + nonisolated func v2SurfaceReorder(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let surfaceId = v2UUID(params, "surface_id") else { + return v2InvalidParam("surface_id") + } - let index = v2Int(params, "index") - let beforeSurfaceId = v2UUID(params, "before_surface_id") - let afterSurfaceId = v2UUID(params, "after_surface_id") - let targetCount = (index != nil ? 1 : 0) + (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) - if targetCount != 1 { - return .err(code: "invalid_params", message: "Specify exactly one of index, before_surface_id, or after_surface_id", data: nil) - } + let index = v2Int(params, "index") + let beforeSurfaceId = v2UUID(params, "before_surface_id") + let afterSurfaceId = v2UUID(params, "after_surface_id") + let targetCount = (index != nil ? 1 : 0) + (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) + if targetCount != 1 { + return .err(code: "invalid_params", message: "Specify exactly one of index, before_surface_id, or after_surface_id", data: nil) + } - var result: V2CallResult = .err(code: "internal_error", message: "Failed to reorder surface", data: nil) - v2MainSync { guard let app = AppDelegate.shared, let located = app.locateSurface(surfaceId: surfaceId), let ws = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), let sourcePane = ws.paneId(forPanelId: surfaceId) else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } let targetIndex: Int @@ -517,29 +476,25 @@ extension TerminalController { guard let anchorPane = ws.paneId(forPanelId: beforeSurfaceId), anchorPane == sourcePane, let anchorIndex = ws.indexInPane(forPanelId: beforeSurfaceId) else { - result = .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil) - return + return .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil) } targetIndex = anchorIndex } else if let afterSurfaceId { guard let anchorPane = ws.paneId(forPanelId: afterSurfaceId), anchorPane == sourcePane, let anchorIndex = ws.indexInPane(forPanelId: afterSurfaceId) else { - result = .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil) - return + return .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil) } targetIndex = anchorIndex + 1 } else { - result = .err(code: "invalid_params", message: "Missing reorder target", data: nil) - return + return .err(code: "invalid_params", message: "Missing reorder target", data: nil) } guard ws.reorderSurface(panelId: surfaceId, toIndex: targetIndex) else { - result = .err(code: "internal_error", message: "Failed to reorder surface", data: nil) - return + return .err(code: "internal_error", message: "Failed to reorder surface", data: nil) } - result = .ok([ + return .ok([ "window_id": located.windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: located.windowId), "workspace_id": ws.id.uuidString, @@ -550,18 +505,14 @@ extension TerminalController { "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) ]) } - - return result } - func v2SurfaceRefresh(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - var result: V2CallResult = .ok(["refreshed": 0]) + nonisolated func v2SurfaceRefresh(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } var refreshedCount = 0 for panel in ws.panels.values { @@ -571,19 +522,18 @@ extension TerminalController { } } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "refreshed": refreshedCount]) + return .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "refreshed": refreshedCount]) } - return result } - func v2SurfaceHealth(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var payload: [String: Any]? + nonisolated func v2SurfaceHealth(params: [String: Any]) -> V2CallResult { v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let panels = orderedPanels(in: ws) let items: [[String: Any]] = panels.enumerated().map { index, panel in var inWindow: Any = NSNull() @@ -601,26 +551,21 @@ extension TerminalController { ] } let windowId = v2ResolveWindowId(tabManager: tabManager) - payload = [ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surfaces": items, "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) - ] - } - - guard let payload else { - return .err(code: "not_found", message: "Workspace not found", data: nil) + ]) } - return .ok(payload) } - func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { - var payload: [String: Any]? - + nonisolated func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { v2MainSync { - guard let app = AppDelegate.shared else { return } + guard let app = AppDelegate.shared else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } struct MappedTerminalLocation { let windowIndex: Int @@ -863,53 +808,42 @@ extension TerminalController { return item } - payload = [ + return .ok([ "count": terminals.count, "terminals": terminals - ] - } - - guard let payload else { - return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + ]) } - return .ok(payload) } - func v2SurfaceSendText(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let text = params["text"] as? String else { - return .err(code: "invalid_params", message: "Missing text", data: nil) - } - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to send text", data: nil) + nonisolated func v2SurfaceSendText(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let text = params["text"] as? String else { + return .err(code: "invalid_params", message: "Missing text", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let surfaceId: UUID? if params["surface_id"] != nil { surfaceId = v2UUID(params, "surface_id") guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) } } else { surfaceId = ws.focusedPanelId } guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } guard let panel = ws.panels[surfaceId] else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } guard let terminalPanel = panel as? TerminalPanel else { - result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) } #if DEBUG let sendStart = ProcessInfo.processInfo.systemUptime @@ -934,102 +868,86 @@ extension TerminalController { "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) #endif - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } - func v2SurfaceSendKey(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let key = v2String(params, "key") else { - return .err(code: "invalid_params", message: "Missing key", data: nil) - } - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to send key", data: nil) + nonisolated func v2SurfaceSendKey(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let surfaceId: UUID? if params["surface_id"] != nil { surfaceId = v2UUID(params, "surface_id") guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) } } else { surfaceId = ws.focusedPanelId } guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } guard let panel = ws.panels[surfaceId] else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } guard let terminalPanel = panel as? TerminalPanel else { - result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) } let surfaceWasReady = terminalPanel.surface.surface != nil guard terminalPanel.surface.sendNamedKey(key) else { - result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) - return + return .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) } if surfaceWasReady { terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") } - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } - func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil) + nonisolated func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let surfaceId: UUID? if params["surface_id"] != nil { surfaceId = v2UUID(params, "surface_id") guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) } } else { surfaceId = ws.focusedPanelId } guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } guard let panel = ws.panels[surfaceId] else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } guard let terminalPanel = panel as? TerminalPanel else { - result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) } guard terminalPanel.performBindingAction("clear_screen") else { - result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil) - return + return .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil) } terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory") let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, @@ -1038,52 +956,44 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - - return result } - func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { + v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } - var includeScrollback = v2Bool(params, "scrollback") ?? false - let lineLimit = v2Int(params, "lines") - if let lineLimit, lineLimit <= 0 { - return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil) - } - if lineLimit != nil { - includeScrollback = true - } + var includeScrollback = v2Bool(params, "scrollback") ?? false + let lineLimit = v2Int(params, "lines") + if let lineLimit, lineLimit <= 0 { + return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil) + } + if lineLimit != nil { + includeScrollback = true + } - var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil) - v2MainSync { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let surfaceId: UUID? if params["surface_id"] != nil { surfaceId = v2UUID(params, "surface_id") guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) } } else { surfaceId = ws.focusedPanelId } guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } guard let panel = ws.panels[surfaceId] else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } guard let terminalPanel = panel as? TerminalPanel else { - result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) } let response = readTerminalTextBase64( @@ -1092,18 +1002,16 @@ extension TerminalController { lineLimit: lineLimit ) guard response.hasPrefix("OK ") else { - result = .err(code: "internal_error", message: response, data: nil) - return + return .err(code: "internal_error", message: response, data: nil) } let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) } guard let text = decoded ?? (base64.isEmpty ? "" : nil) else { - result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil) - return + return .err(code: "internal_error", message: "Failed to decode terminal text", data: nil) } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "text": text, "base64": base64, "workspace_id": ws.id.uuidString, @@ -1114,37 +1022,31 @@ extension TerminalController { "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - return result } - func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to trigger flash", data: nil) + nonisolated func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return .err(code: "not_found", message: "Workspace not found", data: nil) } let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return + return .err(code: "not_found", message: "No focused surface", data: nil) } guard ws.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + return .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) } v2MaybeFocusWindow(for: tabManager) v2MaybeSelectWorkspace(tabManager, workspace: ws) ws.triggerFocusFlash(panelId: surfaceId) - result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) + return .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } - return result } private func sendKeyEvent( surface: ghostty_surface_t, diff --git a/Sources/TerminalController+SurfaceWait.swift b/Sources/TerminalController+SurfaceWait.swift index 3ddcb116..f2020d53 100644 --- a/Sources/TerminalController+SurfaceWait.swift +++ b/Sources/TerminalController+SurfaceWait.swift @@ -170,6 +170,19 @@ final class AgentStateWaitRegistry: @unchecked Sendable { lock.unlock() } +#if DEBUG + func hasPendingWaiterForTesting( + surfaceId: UUID, + condition: AgentStateWaitCondition + ) -> Bool { + lock.lock() + defer { lock.unlock() } + return waiters[surfaceId]?.contains { + $0.condition.rawValue == condition.rawValue + } ?? false + } +#endif + /// Called from the single main-thread mutation point whenever `surfaceId`'s agent state /// changes (including transitioning to `nil` on clear/reset). Fires every waiter whose /// condition `newState` satisfies and leaves the rest registered. `source` is the additive @@ -207,14 +220,14 @@ extension TerminalController { /// true content-changed event. This still satisfies the "single call, no caller-side polling" /// goal of #166: the polling happens once, inside the app, on the connection's own thread, /// and the caller gets exactly one request/response round trip. - private static let surfaceWaitPollInterval: TimeInterval = 0.1 + private nonisolated static let surfaceWaitPollInterval: TimeInterval = 0.1 /// `surface.wait`: block (with timeout) until a surface hits a condition -- new output /// matching a regex `pattern`, the surface's child process exiting (`exit: true`), or its /// reported agent activity state satisfying `agent_state` (#166 task 2: `idle`, `working`, /// `blocked`, or `any_change`). Exactly one of `pattern` / `exit` / `agent_state` must be /// provided. - func v2SurfaceWait(params: [String: Any]) -> V2CallResult { + nonisolated func v2SurfaceWait(params: [String: Any]) -> V2CallResult { let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 30_000) let timeout = Double(timeoutMs) / 1000.0 let deadline = Date().addingTimeInterval(timeout) diff --git a/Sources/TerminalController+System.swift b/Sources/TerminalController+System.swift index cd0a3113..a2ed78aa 100644 --- a/Sources/TerminalController+System.swift +++ b/Sources/TerminalController+System.swift @@ -5,121 +5,204 @@ import Foundation import Bonsplit import WebKit +private struct SystemIdentifyInput: Sendable { + let windowId: UUID? + let workspaceId: UUID? + let surfaceId: UUID? + let callerWorkspaceId: UUID? + let callerSurfaceId: UUID? +} + +private struct SystemIdentifySnapshot { + let focused: [String: Any] + let caller: [String: Any]? + let focusedWindowId: UUID? +} + +private struct SystemTreeInput: Sendable { + let workspaceFilter: UUID? + let includeAllWindows: Bool + let identify: SystemIdentifyInput +} + +private struct SystemTreeSnapshot { + let focused: [String: Any] + let caller: [String: Any]? + let windows: [[String: Any]] + let workspaceFound: Bool +} + extension TerminalController { - func v2Identify(params: [String: Any]) -> [String: Any] { - guard let tabManager = v2ResolveTabManager(params: params) else { + nonisolated func v2Identify(params: [String: Any], requestPolicy: SocketRequestPolicy) -> [String: Any] { + let input = v2SystemIdentifyInput(params: params) + guard let snapshot = v2MainSync({ self.v2SystemIdentifySnapshot(input: input) }) else { return [ - "socket_path": socketPath, + "socket_path": requestPolicy.socketPath, "focused": NSNull(), "caller": NSNull() ] } - var focused: [String: Any] = [:] - v2MainSync { - let windowId = v2ResolveWindowId(tabManager: tabManager) - if let wsId = tabManager.selectedTabId, - let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - let paneUUID = ws.bonsplitController.focusedPaneId?.id - let surfaceUUID = ws.focusedPanelId - focused = [ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), - "pane_id": v2OrNull(paneUUID?.uuidString), - "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), - "surface_id": v2OrNull(surfaceUUID?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: surfaceUUID), - "tab_id": v2OrNull(surfaceUUID?.uuidString), - "tab_ref": v2TabRef(uuid: surfaceUUID), - "surface_type": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType.rawValue }), - "is_browser_surface": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType == .browser }) - ] - } else { - focused = [ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId) - ] - } - } - - // Optionally validate a caller-provided location (useful for agents calling from inside a surface). - var resolvedCaller: [String: Any]? = nil - if let callerObj = params["caller"] as? [String: Any], - let wsId = v2UUIDAny(callerObj["workspace_id"]) { - let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"]) - v2MainSync { - let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager - if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) { - let callerWindowId = v2ResolveWindowId(tabManager: callerTabManager) - var payload: [String: Any] = [ - "window_id": v2OrNull(callerWindowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: callerWindowId), - "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) - ] - - if let surfaceId, ws.panels[surfaceId] != nil { - let paneUUID = ws.paneId(forPanelId: surfaceId)?.id - payload["surface_id"] = surfaceId.uuidString - payload["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId) - payload["tab_id"] = surfaceId.uuidString - payload["tab_ref"] = v2TabRef(uuid: surfaceId) - payload["surface_type"] = v2OrNull(ws.panels[surfaceId]?.panelType.rawValue) - payload["is_browser_surface"] = v2OrNull(ws.panels[surfaceId]?.panelType == .browser) - payload["pane_id"] = v2OrNull(paneUUID?.uuidString) - payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneUUID) - } else { - payload["surface_id"] = NSNull() - payload["surface_ref"] = NSNull() - payload["tab_id"] = NSNull() - payload["tab_ref"] = NSNull() - payload["surface_type"] = NSNull() - payload["is_browser_surface"] = NSNull() - payload["pane_id"] = NSNull() - payload["pane_ref"] = NSNull() - } - resolvedCaller = payload - } - } - } - return [ - "socket_path": socketPath, - "focused": focused.isEmpty ? NSNull() : focused, - "caller": v2OrNull(resolvedCaller) + "socket_path": requestPolicy.socketPath, + "focused": snapshot.focused.isEmpty ? NSNull() : snapshot.focused, + "caller": v2OrNull(snapshot.caller) ] } - func v2SystemTree(params: [String: Any]) -> V2CallResult { + nonisolated func v2SystemTree(params: [String: Any], requestPolicy: SocketRequestPolicy) -> V2CallResult { let workspaceFilter = v2UUID(params, "workspace_id") if params["workspace_id"] != nil && workspaceFilter == nil { return v2InvalidParam("workspace_id") } let includeAllWindows = v2Bool(params, "all_windows") ?? false + let caller = params["caller"] as? [String: Any] + let identifyInput = v2SystemIdentifyInput(caller: caller?.isEmpty == false ? caller : nil) + let snapshot = v2MainSync { + self.v2SystemTreeSnapshot(input: SystemTreeInput( + workspaceFilter: workspaceFilter, + includeAllWindows: includeAllWindows, + identify: identifyInput + )) + } - var identifyParams: [String: Any] = [:] - if let caller = params["caller"] as? [String: Any], !caller.isEmpty { - identifyParams["caller"] = caller + if let workspaceFilter, !snapshot.workspaceFound { + return .err( + code: "not_found", + message: "Workspace not found", + data: [ + "workspace_id": workspaceFilter.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceFilter) + ] + ) } - let identifyPayload = v2Identify(params: identifyParams) - let focused = identifyPayload["focused"] as? [String: Any] ?? [:] - let caller = identifyPayload["caller"] as? [String: Any] ?? [:] - let focusedWindowId = v2UUIDAny(focused["window_id"]) ?? v2UUIDAny(focused["window_ref"]) - var windowNodes: [[String: Any]] = [] - var workspaceFound = (workspaceFilter == nil) + return .ok([ + "active": snapshot.focused.isEmpty ? (NSNull() as Any) : snapshot.focused, + "caller": v2OrNull(snapshot.caller), + "windows": snapshot.windows + ]) + } - v2MainSync { - guard let app = AppDelegate.shared else { return } + private nonisolated func v2SystemIdentifyInput( + params: [String: Any] = [:], + caller: [String: Any]? = nil + ) -> SystemIdentifyInput { + let callerObject = caller ?? (params["caller"] as? [String: Any]) + return SystemIdentifyInput( + windowId: v2UUID(params, "window_id"), + workspaceId: v2UUID(params, "workspace_id"), + surfaceId: v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id"), + callerWorkspaceId: v2UUIDAny(callerObject?["workspace_id"]), + callerSurfaceId: v2UUIDAny(callerObject?["surface_id"]) + ?? v2UUIDAny(callerObject?["tab_id"]) + ) + } + + @MainActor + private func v2SystemIdentifySnapshot(input: SystemIdentifyInput) -> SystemIdentifySnapshot? { + let manager: TabManager? + if let windowId = input.windowId { + manager = AppDelegate.shared?.tabManagerFor(windowId: windowId) + } else { + var resolvedManager: TabManager? + if let workspaceId = input.workspaceId { + resolvedManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) + } + if resolvedManager == nil, let surfaceId = input.surfaceId { + resolvedManager = AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager + } + manager = resolvedManager ?? tabManager + } + guard let manager else { return nil } + + let windowId = AppDelegate.shared?.windowId(for: manager) + let focused: [String: Any] + if let workspaceId = manager.selectedTabId, + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) { + let paneId = workspace.bonsplitController.focusedPaneId?.id + let surfaceId = workspace.focusedPanelId + focused = [ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "pane_id": v2OrNull(paneId?.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneId), + "surface_id": v2OrNull(surfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "tab_id": v2OrNull(surfaceId?.uuidString), + "tab_ref": v2TabRef(uuid: surfaceId), + "surface_type": v2OrNull(surfaceId.flatMap { workspace.panels[$0]?.panelType.rawValue }), + "is_browser_surface": v2OrNull(surfaceId.flatMap { workspace.panels[$0]?.panelType == .browser }) + ] + } else { + focused = [ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ] + } + + var callerPayload: [String: Any]? + if let callerWorkspaceId = input.callerWorkspaceId { + let callerManager = AppDelegate.shared?.tabManagerFor(tabId: callerWorkspaceId) ?? manager + if let workspace = callerManager.tabs.first(where: { $0.id == callerWorkspaceId }) { + let callerWindowId = AppDelegate.shared?.windowId(for: callerManager) + var payload: [String: Any] = [ + "window_id": v2OrNull(callerWindowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: callerWindowId), + "workspace_id": callerWorkspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: callerWorkspaceId) + ] + + if let callerSurfaceId = input.callerSurfaceId, + workspace.panels[callerSurfaceId] != nil { + let paneId = workspace.paneId(forPanelId: callerSurfaceId)?.id + payload["surface_id"] = callerSurfaceId.uuidString + payload["surface_ref"] = v2Ref(kind: .surface, uuid: callerSurfaceId) + payload["tab_id"] = callerSurfaceId.uuidString + payload["tab_ref"] = v2TabRef(uuid: callerSurfaceId) + payload["surface_type"] = v2OrNull(workspace.panels[callerSurfaceId]?.panelType.rawValue) + payload["is_browser_surface"] = v2OrNull(workspace.panels[callerSurfaceId]?.panelType == .browser) + payload["pane_id"] = v2OrNull(paneId?.uuidString) + payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneId) + } else { + payload["surface_id"] = NSNull() + payload["surface_ref"] = NSNull() + payload["tab_id"] = NSNull() + payload["tab_ref"] = NSNull() + payload["surface_type"] = NSNull() + payload["is_browser_surface"] = NSNull() + payload["pane_id"] = NSNull() + payload["pane_ref"] = NSNull() + } + callerPayload = payload + } + } + + return SystemIdentifySnapshot( + focused: focused, + caller: callerPayload, + focusedWindowId: windowId + ) + } + + @MainActor + private func v2SystemTreeSnapshot(input: SystemTreeInput) -> SystemTreeSnapshot { + let identifySnapshot = v2SystemIdentifySnapshot(input: input.identify) + let focused = identifySnapshot?.focused ?? [:] + let caller = identifySnapshot?.caller + var windows: [[String: Any]] = [] + var workspaceFound = (input.workspaceFilter == nil) + + if let app = AppDelegate.shared { let summaries = app.listMainWindowSummaries() - let defaultWindowId = focusedWindowId ?? summaries.first?.windowId + let defaultWindowId = identifySnapshot?.focusedWindowId ?? summaries.first?.windowId for (windowIndex, summary) in summaries.enumerated() { guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue } - if let workspaceFilter { + if let workspaceFilter = input.workspaceFilter { guard let workspaceIndex = manager.tabs.firstIndex(where: { $0.id == workspaceFilter }) else { continue } @@ -129,7 +212,7 @@ extension TerminalController { index: workspaceIndex, selected: workspace.id == manager.selectedTabId ) - windowNodes = [ + windows = [ v2TreeWindowNode( summary: summary, index: windowIndex, @@ -140,44 +223,33 @@ extension TerminalController { break } - if !includeAllWindows && summary.windowId != defaultWindowId { + if !input.includeAllWindows && summary.windowId != defaultWindowId { continue } - let workspaceNodesForWindow = manager.tabs.enumerated().map { workspaceIndex, workspace in + let workspaceNodes = manager.tabs.enumerated().map { workspaceIndex, workspace in v2TreeWorkspaceNode( workspace: workspace, index: workspaceIndex, selected: workspace.id == manager.selectedTabId ) } - - windowNodes.append( + windows.append( v2TreeWindowNode( summary: summary, index: windowIndex, - workspaceNodes: workspaceNodesForWindow + workspaceNodes: workspaceNodes ) ) } } - if let workspaceFilter, !workspaceFound { - return .err( - code: "not_found", - message: "Workspace not found", - data: [ - "workspace_id": workspaceFilter.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceFilter) - ] - ) - } - - return .ok([ - "active": focused.isEmpty ? (NSNull() as Any) : focused, - "caller": caller.isEmpty ? (NSNull() as Any) : caller, - "windows": windowNodes - ]) + return SystemTreeSnapshot( + focused: focused, + caller: caller, + windows: windows, + workspaceFound: workspaceFound + ) } private func v2TreeWindowNode( @@ -290,21 +362,21 @@ extension TerminalController { "panes": panes ] } - func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { + nonisolated func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { let workspaceId = v2UUID(params, "workspace_id") let windowId = v2UUID(params, "window_id") let shouldActivate = v2FocusAllowed(requested: v2Bool(params, "activate") ?? false) - DispatchQueue.main.async { - let targetWindow: NSWindow? - if let windowId, let app = AppDelegate.shared { - targetWindow = app.mainWindow(for: windowId) - } else if let workspaceId, let app = AppDelegate.shared { - targetWindow = app.mainWindowContainingWorkspace(workspaceId) - } else { - targetWindow = nil - } - + DispatchQueue.main.async { @MainActor in if shouldActivate { + let targetWindow: NSWindow? + if let windowId, let app = AppDelegate.shared { + targetWindow = app.mainWindow(for: windowId) + } else if let workspaceId, let app = AppDelegate.shared { + targetWindow = app.mainWindowContainingWorkspace(workspaceId) + } else { + targetWindow = nil + } + if let targetWindow { targetWindow.makeKeyAndOrderFront(nil) NSRunningApplication.current.activate(options: [.activateAllWindows]) @@ -318,21 +390,22 @@ extension TerminalController { return .ok(["opened": true]) } - func v2SettingsOpen(params: [String: Any]) -> V2CallResult { + nonisolated func v2SettingsOpen(params: [String: Any]) -> V2CallResult { let targetRaw = v2String(params, "target") let shouldActivate = v2FocusAllowed(requested: v2Bool(params, "activate") ?? true) + let keyboardShortcutsTarget = SettingsNavigationTarget.keyboardShortcuts.rawValue - let navigationTarget: SettingsNavigationTarget? switch targetRaw { case nil: - navigationTarget = nil - case SettingsNavigationTarget.keyboardShortcuts.rawValue: - navigationTarget = .keyboardShortcuts + break + case keyboardShortcutsTarget: + break default: return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""]) } - DispatchQueue.main.async { + DispatchQueue.main.async { @MainActor in + let navigationTarget = targetRaw.flatMap { SettingsNavigationTarget(rawValue: $0) } if shouldActivate { AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget) } else { @@ -341,11 +414,11 @@ extension TerminalController { } return .ok([ "opened": true, - "target": navigationTarget?.rawValue ?? "general", + "target": targetRaw ?? "general", ]) } - func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult { + nonisolated func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult { return .err( code: "feedback_disabled", message: "feedback submission is disabled; report issues at https://github.com/darkroomengineering/programa/issues", @@ -355,36 +428,37 @@ extension TerminalController { // MARK: - V2 App Focus Methods - func v2AppFocusOverride(params: [String: Any]) -> V2CallResult { + nonisolated func v2AppFocusOverride(params: [String: Any]) -> V2CallResult { // Accept either: // - state: "active" | "inactive" | "clear" // - focused: true/false/null + let requestedOverride: Bool? if let state = v2String(params, "state")?.lowercased() { switch state { case "active": - AppFocusState.overrideIsFocused = true + requestedOverride = true case "inactive": - AppFocusState.overrideIsFocused = false + requestedOverride = false case "clear", "none": - AppFocusState.overrideIsFocused = nil + requestedOverride = nil default: return .err(code: "invalid_params", message: "Invalid state (active|inactive|clear)", data: ["state": state]) } } else if params.keys.contains("focused") { - if let focused = v2Bool(params, "focused") { - AppFocusState.overrideIsFocused = focused - } else { - AppFocusState.overrideIsFocused = nil - } + requestedOverride = v2Bool(params, "focused") } else { return .err(code: "invalid_params", message: "Missing state or focused", data: nil) } - let overrideVal: Any = v2OrNull(AppFocusState.overrideIsFocused.map { $0 as Any }) + let appliedOverride: Bool? = v2MainSync { + AppFocusState.overrideIsFocused = requestedOverride + return AppFocusState.overrideIsFocused + } + let overrideVal: Any = v2OrNull(appliedOverride.map { $0 as Any }) return .ok(["override": overrideVal]) } - func v2AppSimulateActive() -> V2CallResult { + nonisolated func v2AppSimulateActive() -> V2CallResult { v2MainSync { AppDelegate.shared?.applicationDidBecomeActive( Notification(name: NSApplication.didBecomeActiveNotification) @@ -396,7 +470,7 @@ extension TerminalController { /// Mirrors v1's `reload_config`: this is a rare, user/agent-triggered configuration /// reload rather than high-frequency telemetry, so — matching the v1 handler, which /// itself calls `v2MainSync` directly — it is allowed to synchronize with the main actor. - func v2AppReloadConfig(params: [String: Any]) -> V2CallResult { + nonisolated func v2AppReloadConfig(params: [String: Any]) -> V2CallResult { v2MainSync { GhosttyApp.shared.reloadConfiguration(source: "socket.v2.app.reload_config") } @@ -406,7 +480,7 @@ extension TerminalController { /// Read-only `NSWorkspace`/filesystem queries, no arguments, no AppKit UI /// mutation -- per the socket command threading policy this runs off-main /// (no `v2MainSync`), same as other query commands. - func v2AppBrowsers() -> V2CallResult { + nonisolated func v2AppBrowsers() -> V2CallResult { let statuses = BrowserAvailability.detectStatuses() let defaultBrowser = BrowserAvailability.resolveDefaultBrowser() let browsers: [[String: Any]] = statuses.map { status in diff --git a/Sources/TerminalController+Telemetry.swift b/Sources/TerminalController+Telemetry.swift index 5dfc55d5..940efad4 100644 --- a/Sources/TerminalController+Telemetry.swift +++ b/Sources/TerminalController+Telemetry.swift @@ -14,11 +14,11 @@ extension TerminalController { // workspace/surface the same way the v1 explicit-scope fast paths do (`AppDelegate.shared? // .tabManagerFor(tabId:)` + linear tab lookup) but dispatch the mutation asynchronously and // return an optimistic `ok` result immediately, matching v1's fire-and-forget "OK" semantics. - private func v2ScheduleTelemetryMutation( + private nonisolated func v2ScheduleTelemetryMutation( workspaceId: UUID, - _ mutation: @escaping (TabManager, Workspace) -> Void + _ mutation: @escaping @MainActor @Sendable (TabManager, Workspace) -> Void ) { - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { @MainActor [weak self] in // Prefer explicit window-routed lookup (mirrors `v2ResolveTabManager`), but fall // back to `self.tabManager` — the TabManager registered via `start(tabManager:)`. // Without this fallback, a workspace that only exists in the TabManager passed to @@ -34,10 +34,10 @@ extension TerminalController { } } - private func v2ScheduleSurfaceTelemetryMutation( + private nonisolated func v2ScheduleSurfaceTelemetryMutation( workspaceId: UUID, surfaceId: UUID, - _ mutation: @escaping (TabManager, Workspace, UUID) -> Void + _ mutation: @escaping @MainActor @Sendable (TabManager, Workspace, UUID) -> Void ) { v2ScheduleTelemetryMutation(workspaceId: workspaceId) { tabManager, tab in let validSurfaceIds = Set(tab.panels.keys) @@ -46,11 +46,11 @@ extension TerminalController { mutation(tabManager, tab, surfaceId) } } - func v2SurfaceReportTTY(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportTTY(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - let requestedSurfaceId = v2UUID(params, "surface_id") + let requestedSurfaceId = v2CachedUUID(params, "surface_id") if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { return v2InvalidParam("surface_id") } @@ -104,11 +104,11 @@ extension TerminalController { ]) } - func v2SurfacePortsKick(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfacePortsKick(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - let requestedSurfaceId = v2UUID(params, "surface_id") + let requestedSurfaceId = v2CachedUUID(params, "surface_id") if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { return v2InvalidParam("surface_id") } @@ -175,11 +175,11 @@ extension TerminalController { // fallback), so they always take the async fast path v1 took when both --tab and --panel // were supplied explicitly. - func v2SurfaceReportPwd(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportPwd(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } guard let path = v2RawString(params, "path")?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -207,11 +207,11 @@ extension TerminalController { ]) } - func v2SurfaceReportShellState(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportShellState(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } guard let rawState = v2RawString(params, "state"), @@ -253,11 +253,11 @@ extension TerminalController { return .ok(baseResult) } - func v2SurfaceReportGitBranch(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportGitBranch(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } guard let branch = v2RawString(params, "branch")?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -287,11 +287,11 @@ extension TerminalController { ]) } - func v2SurfaceClearGitBranch(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceClearGitBranch(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } @@ -310,11 +310,11 @@ extension TerminalController { /// Reports a lifecycle-hook-driven agent activity state for a surface (issue #164, v1 /// hook tier). Called exclusively by the shipped Claude Code/Codex/OpenCode hook /// wrappers (CLI+Hooks.swift) — there is no heuristic/screen-rule fallback in this tier. - func v2SurfaceReportAgentState(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportAgentState(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } guard let rawState = v2RawString(params, "state"), @@ -350,11 +350,11 @@ extension TerminalController { ]) } - func v2SurfaceClearAgentState(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceClearAgentState(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } @@ -370,11 +370,11 @@ extension TerminalController { ]) } - func v2SurfaceReportPullRequest(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportPullRequest(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } guard let number = v2Int(params, "number"), number > 0 else { @@ -431,7 +431,7 @@ extension TerminalController { ) } - v2ScheduleSurfaceTelemetryMutation(workspaceId: workspaceId, surfaceId: surfaceId) { _, tab, sid in + v2ScheduleSurfaceTelemetryMutation(workspaceId: workspaceId, surfaceId: surfaceId) { [checks] _, tab, sid in guard Self.shouldReplacePullRequest( current: tab.panelPullRequests[sid], number: number, @@ -468,11 +468,11 @@ extension TerminalController { ]) } - func v2SurfaceClearPullRequest(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceClearPullRequest(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } @@ -488,25 +488,32 @@ extension TerminalController { ]) } - func v2SurfaceReportPorts(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceReportPorts(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - guard let surfaceId = v2UUID(params, "surface_id") else { + guard let surfaceId = v2CachedUUID(params, "surface_id") else { return v2InvalidParam("surface_id") } + if let rawPortValues = params["ports"] as? [Any], + rawPortValues.count > SidebarTelemetryLimits.maxReportedPorts { + return .err( + code: "invalid_params", + message: "ports exceeds the limit of 65535 entries", + data: nil + ) + } guard let rawPorts = v2IntArray(params, "ports"), !rawPorts.isEmpty else { return v2InvalidParam("ports") } guard rawPorts.allSatisfy({ $0 > 0 && $0 <= 65535 }) else { return .err(code: "invalid_params", message: "Invalid port — must be 1-65535", data: nil) } + let ports = Array(Set(rawPorts)).sorted() v2ScheduleSurfaceTelemetryMutation(workspaceId: workspaceId, surfaceId: surfaceId) { _, tab, sid in - guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[sid], next: rawPorts) else { - return - } - tab.surfaceListeningPorts[sid] = rawPorts + guard tab.surfaceListeningPorts[sid] != ports else { return } + tab.surfaceListeningPorts[sid] = ports tab.recomputeListeningPorts() } @@ -515,15 +522,15 @@ extension TerminalController { "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "ports": rawPorts, + "ports": ports, ]) } - func v2SurfaceClearPorts(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2SurfaceClearPorts(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } - let requestedSurfaceId = v2UUID(params, "surface_id") + let requestedSurfaceId = v2CachedUUID(params, "surface_id") if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { return v2InvalidParam("surface_id") } @@ -593,8 +600,8 @@ extension TerminalController { // reads (list_status/list_log/sidebar_state) are exact-snapshot queries and use the // v2MainSync pattern shared by sibling v2 read methods. - func v2WorkspaceSetStatus(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceSetStatus(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let key = v2String(params, "key") else { @@ -683,7 +690,7 @@ extension TerminalController { pidValue = pid_t(rawPid) } - v2ScheduleTelemetryMutation(workspaceId: workspaceId) { [weak self] _, tab in + v2ScheduleTelemetryMutation(workspaceId: workspaceId) { [weak self, priority, url, pidValue] _, tab in guard let self else { return } guard Self.shouldReplaceStatusEntry( current: tab.statusEntries[key], @@ -727,8 +734,8 @@ extension TerminalController { ]) } - func v2WorkspaceClearStatus(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceClearStatus(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let key = v2String(params, "key") else { @@ -752,14 +759,14 @@ extension TerminalController { ]) } - func v2WorkspaceListStatus(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + nonisolated func v2WorkspaceListStatus(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let entries: [[String: Any]] = ws.sidebarStatusEntriesInDisplayOrder().map { entry in [ "key": entry.key, @@ -771,17 +778,16 @@ extension TerminalController { "format": entry.format.rawValue, ] } - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "entries": entries, ]) } - return result } - func v2WorkspaceLog(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceLog(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let message = v2RawString(params, "message"), !message.isEmpty else { @@ -829,8 +835,8 @@ extension TerminalController { ]) } - func v2WorkspaceClearLog(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceClearLog(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } @@ -845,22 +851,23 @@ extension TerminalController { ]) } - func v2WorkspaceListLog(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2WorkspaceListLog(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } - var limit: Int? - if v2HasNonNullParam(params, "limit") { - guard let parsedLimit = v2Int(params, "limit"), parsedLimit >= 0 else { - return .err(code: "invalid_params", message: "Invalid limit — must be >= 0", data: nil) + var limit: Int? + if v2HasNonNullParam(params, "limit") { + guard let parsedLimit = v2Int(params, "limit"), parsedLimit >= 0 else { + return .err(code: "invalid_params", message: "Invalid limit — must be >= 0", data: nil) + } + limit = parsedLimit } - limit = parsedLimit - } - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let source = limit.map { Array(ws.logEntries.suffix($0)) } ?? ws.logEntries let entries: [[String: Any]] = source.map { entry in [ @@ -870,17 +877,16 @@ extension TerminalController { "timestamp": entry.timestamp.timeIntervalSince1970, ] } - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "entries": entries, ]) } - return result } - func v2WorkspaceSetProgress(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceSetProgress(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let rawValue = v2Double(params, "value"), rawValue.isFinite else { @@ -914,8 +920,8 @@ extension TerminalController { ]) } - func v2WorkspaceClearProgress(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceClearProgress(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } @@ -930,14 +936,14 @@ extension TerminalController { ]) } - func v2WorkspaceSidebarState(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + nonisolated func v2WorkspaceSidebarState(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } var focusedCwd: Any = NSNull() if let focused = ws.focusedPanelId, let focusedDir = ws.panelDirectories[focused] { @@ -986,7 +992,7 @@ extension TerminalController { ["message": entry.message, "level": entry.level.rawValue, "source": v2OrNull(entry.source)] } - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "color": v2OrNull(ws.customColor), @@ -1004,11 +1010,10 @@ extension TerminalController { "recent_log_entries": recentLogEntries, ]) } - return result } - func v2WorkspaceClearAgentPID(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceClearAgentPID(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let key = v2String(params, "key") else { @@ -1038,8 +1043,8 @@ extension TerminalController { /// Mirrors v1's `set_agent_pid [--tab=X]`: registers a PID for stale-session /// detection/OSC suppression without setting a visible status entry (unlike /// `workspace.set_status`, which also accepts an optional `pid`). - func v2WorkspaceSetAgentPID(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceSetAgentPID(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let key = v2String(params, "key") else { @@ -1080,8 +1085,8 @@ extension TerminalController { /// Mirrors v1's `report_meta_block [--priority=N] [--tab=X] -- `: sets a /// freeform sidebar markdown block, distinct from `workspace.set_status`'s single-line /// key/value entries. - func v2WorkspaceReportMetaBlock(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { + nonisolated func v2WorkspaceReportMetaBlock(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2CachedUUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } guard let key = v2String(params, "key") else { @@ -1121,7 +1126,7 @@ extension TerminalController { priority = max(-9999, min(9999, rawPriority)) } - v2ScheduleTelemetryMutation(workspaceId: workspaceId) { _, tab in + v2ScheduleTelemetryMutation(workspaceId: workspaceId) { [priority] _, tab in guard Self.shouldReplaceMetadataBlock( current: tab.metadataBlocks[key], key: key, @@ -1152,65 +1157,62 @@ extension TerminalController { /// `DispatchQueue.main.sync` implementation — it resolves and mutates on the main actor via /// `v2MainSync` rather than firing an async `v2ScheduleTelemetryMutation`. This is a rare, /// agent/test-triggered command, not a high-frequency telemetry path. - func v2WorkspaceClearMetaBlock(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let key = v2String(params, "key") else { - return .err(code: "invalid_params", message: "Missing key", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + nonisolated func v2WorkspaceClearMetaBlock(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = v2String(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let found = ws.metadataBlocks.removeValue(forKey: key) != nil - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "key": key, "found": found, ]) } - return result } /// Mirrors v1's `list_meta_blocks [--tab=X]`. - func v2WorkspaceListMetaBlocks(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + nonisolated func v2WorkspaceListMetaBlocks(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let blocks: [[String: Any]] = ws.sidebarMetadataBlocksInDisplayOrder().map { block in ["key": block.key, "markdown": block.markdown, "priority": block.priority] } - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "blocks": blocks, ]) } - return result } /// Mirrors v1's `reset_sidebar [--tab=X]`. - func v2WorkspaceResetSidebar(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + nonisolated func v2WorkspaceResetSidebar(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } ws.resetSidebarContext(reason: "v2.workspace.reset_sidebar") - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), ]) } - return result } private func refreshTrackedAgentPorts(for tab: Workspace) { let agentPIDs = Set(tab.agentPIDs.values.compactMap { $0 > 0 ? Int($0) : nil }) diff --git a/Sources/TerminalController+Window.swift b/Sources/TerminalController+Window.swift index 5e177903..72167f98 100644 --- a/Sources/TerminalController+Window.swift +++ b/Sources/TerminalController+Window.swift @@ -8,7 +8,7 @@ import WebKit extension TerminalController { // MARK: - V2 Window Methods - func v2WindowList(params _: [String: Any]) -> V2CallResult { + nonisolated func v2WindowList(params _: [String: Any]) -> V2CallResult { let windows = v2MainSync { AppDelegate.shared?.listMainWindowSummaries() } ?? [] let payload: [[String: Any]] = windows.enumerated().map { index, item in return [ @@ -25,7 +25,7 @@ extension TerminalController { return .ok(["windows": payload]) } - func v2WindowCurrent(params _: [String: Any]) -> V2CallResult { + nonisolated func v2WindowCurrent(params _: [String: Any]) -> V2CallResult { enum Resolution { case unavailable case notFound @@ -49,7 +49,7 @@ extension TerminalController { } } - func v2WindowFocus(params: [String: Any]) -> V2CallResult { + nonisolated func v2WindowFocus(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } @@ -65,7 +65,7 @@ extension TerminalController { ]) } - func v2WindowCreate(params _: [String: Any]) -> V2CallResult { + nonisolated func v2WindowCreate(params _: [String: Any]) -> V2CallResult { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } @@ -81,7 +81,7 @@ extension TerminalController { ]) } - func v2WindowClose(params: [String: Any]) -> V2CallResult { + nonisolated func v2WindowClose(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return v2InvalidParam("window_id") } diff --git a/Sources/TerminalController+Workspace.swift b/Sources/TerminalController+Workspace.swift index dde96c57..97a240c4 100644 --- a/Sources/TerminalController+Workspace.swift +++ b/Sources/TerminalController+Workspace.swift @@ -31,65 +31,62 @@ extension TerminalController { return payload } - func v2WorkspaceList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2WorkspaceList(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } - var workspaces: [[String: Any]] = [] - v2MainSync { - workspaces = tabManager.tabs.enumerated().map { index, ws in + let workspaces = tabManager.tabs.enumerated().map { index, ws in v2WorkspaceSummaryPayload( workspace: ws, index: index, selected: ws.id == tabManager.selectedTabId ) } + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspaces": workspaces + ]) } - - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspaces": workspaces - ]) } - func v2WorkspaceCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + nonisolated func v2WorkspaceCreate(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } - let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) - let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil + let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) + let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil - let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) - let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil + let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil - let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] - let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in - let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { return } - result[key] = pair.value - } - let cwd: String? - if let workingDirectory { - cwd = workingDirectory - } else if let raw = params["cwd"] { - guard let str = raw as? String else { - return .err(code: "invalid_params", message: "cwd must be a string", data: nil) - } - cwd = str - } else { - cwd = nil - } + let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] + let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + result[key] = pair.value + } + let cwd: String? + if let workingDirectory { + cwd = workingDirectory + } else if let raw = params["cwd"] { + guard let str = raw as? String else { + return .err(code: "invalid_params", message: "cwd must be a string", data: nil) + } + cwd = str + } else { + cwd = nil + } - let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) - let title = (requestedTitle?.isEmpty == false) ? requestedTitle : nil - let description = v2RawString(params, "description") + let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = (requestedTitle?.isEmpty == false) ? requestedTitle : nil + let description = v2RawString(params, "description") - var newId: UUID? - let shouldFocus = v2FocusAllowed() - v2MainSync { + let shouldFocus = v2FocusAllowed() let ws = tabManager.addWorkspace( title: title, workingDirectory: cwd, @@ -99,126 +96,109 @@ extension TerminalController { eagerLoadTerminal: !shouldFocus ) ws.setCustomDescription(description) - newId = ws.id - } - - guard let newId else { - return .err(code: "internal_error", message: "Failed to create workspace", data: nil) + let newId = ws.id + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": newId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: newId) + ]) } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": newId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: newId) - ]) } - func v2WorkspaceSelect(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let wsId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } + nonisolated func v2WorkspaceSelect(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let wsId = v2UUID(params, "workspace_id") else { + return v2InvalidParam("workspace_id") + } - var success = false - v2MainSync { - if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - // If this workspace belongs to another window, bring it forward so focus is visible. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - tabManager.selectWorkspace(ws) - success = true + guard let ws = tabManager.tabs.first(where: { $0.id == wsId }) else { + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": wsId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) + ]) } - } + // If this workspace belongs to another window, bring it forward so focus is visible. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectWorkspace(ws) - let windowId = v2ResolveWindowId(tabManager: tabManager) - return success - ? .ok([ + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) ]) - : .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) - ]) - } - func v2WorkspaceCurrent(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) } - var wsId: UUID? - var wsPayload: [String: Any]? - v2MainSync { - wsId = tabManager.selectedTabId - if let wsId, let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { + } + nonisolated func v2WorkspaceCurrent(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let wsId = tabManager.selectedTabId else { + return .err(code: "not_found", message: "No workspace selected", data: nil) + } + let wsPayload: [String: Any]? + if let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { let index = tabManager.tabs.firstIndex(where: { $0.id == wsId }) wsPayload = v2WorkspaceSummaryPayload( workspace: workspace, index: index, selected: true ) + } else { + wsPayload = nil } - } - guard let wsId else { - return .err(code: "not_found", message: "No workspace selected", data: nil) - } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), - "workspace": wsPayload ?? NSNull() - ]) - } - func v2WorkspaceClose(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let wsId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } - - var found = false - var protected = false - v2MainSync { - if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - guard tabManager.canCloseWorkspace(ws) else { - protected = true - found = true - return - } - tabManager.closeWorkspace(ws) - found = true - } - } - - let windowId = v2ResolveWindowId(tabManager: tabManager) - if protected { - return .err(code: "protected", message: workspaceCloseProtectedMessage(), data: [ + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), - "pinned": true + "workspace": wsPayload ?? NSNull() ]) } - return found - ? .ok([ + } + nonisolated func v2WorkspaceClose(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let wsId = v2UUID(params, "workspace_id") else { + return v2InvalidParam("workspace_id") + } + guard let ws = tabManager.tabs.first(where: { $0.id == wsId }) else { + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": wsId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) + ]) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + guard tabManager.canCloseWorkspace(ws) else { + return .err(code: "protected", message: workspaceCloseProtectedMessage(), data: [ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": wsId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), + "pinned": true + ]) + } + tabManager.closeWorkspace(ws) + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) ]) - : .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) - ]) + } } private func workspaceCloseProtectedMessage() -> String { @@ -228,28 +208,24 @@ extension TerminalController { ) } - func v2WorkspaceMoveToWindow(params: [String: Any]) -> V2CallResult { - guard let wsId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } - guard let windowId = v2UUID(params, "window_id") else { - return v2InvalidParam("window_id") - } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) + nonisolated func v2WorkspaceMoveToWindow(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let wsId = v2UUID(params, "workspace_id") else { + return v2InvalidParam("workspace_id") + } + guard let windowId = v2UUID(params, "window_id") else { + return v2InvalidParam("window_id") + } + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) - v2MainSync { guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId) else { - result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) - return + return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) } guard let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { - result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowId.uuidString]) - return + return .err(code: "not_found", message: "Window not found", data: ["window_id": windowId.uuidString]) } guard let ws = srcTM.detachWorkspace(tabId: wsId) else { - result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) - return + return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString]) } dstTM.attachWorkspace(ws, select: focus) @@ -257,189 +233,185 @@ extension TerminalController { _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) setActiveTabManager(dstTM) } - result = .ok([ + return .ok([ "workspace_id": wsId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), "window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - return result } - func v2WorkspaceReorder(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let workspaceId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } + nonisolated func v2WorkspaceReorder(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return v2InvalidParam("workspace_id") + } - let index = v2Int(params, "index") - let beforeId = v2UUID(params, "before_workspace_id") - let afterId = v2UUID(params, "after_workspace_id") + let index = v2Int(params, "index") + let beforeId = v2UUID(params, "before_workspace_id") + let afterId = v2UUID(params, "after_workspace_id") - let targetCount = (index != nil ? 1 : 0) + (beforeId != nil ? 1 : 0) + (afterId != nil ? 1 : 0) - if targetCount != 1 { - return .err( - code: "invalid_params", - message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id", - data: nil - ) - } + let targetCount = (index != nil ? 1 : 0) + (beforeId != nil ? 1 : 0) + (afterId != nil ? 1 : 0) + if targetCount != 1 { + return .err( + code: "invalid_params", + message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id", + data: nil + ) + } - var moved = false - var newIndex: Int? - v2MainSync { + let moved: Bool if let index { moved = tabManager.reorderWorkspace(tabId: workspaceId, toIndex: index) } else { moved = tabManager.reorderWorkspace(tabId: workspaceId, before: beforeId, after: afterId) } - newIndex = tabManager.tabs.firstIndex(where: { $0.id == workspaceId }) - } + let newIndex = tabManager.tabs.firstIndex(where: { $0.id == workspaceId }) - guard moved else { - return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceId.uuidString]) - } + guard moved else { + return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceId.uuidString]) + } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "index": v2OrNull(newIndex) - ]) - } - func v2WorkspaceRename(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let workspaceId = v2UUID(params, "workspace_id") else { - return v2InvalidParam("workspace_id") - } - guard let titleRaw = v2String(params, "title"), - !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return v2InvalidParam("title") + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "index": v2OrNull(newIndex) + ]) } + } + nonisolated func v2WorkspaceRename(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return v2InvalidParam("workspace_id") + } + guard let titleRaw = v2String(params, "title"), + !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return v2InvalidParam("title") + } - let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) - var renamed = false - v2MainSync { - guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } + let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard tabManager.tabs.contains(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) + ]) + } tabManager.setCustomTitle(tabId: workspaceId, title: title) - renamed = true - } - guard renamed else { - return .err(code: "not_found", message: "Workspace not found", data: [ + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "title": title ]) } - - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "title": title - ]) } - func v2WorkspaceNext(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) - v2MainSync { - guard tabManager.selectedTabId != nil else { return } + nonisolated func v2WorkspaceNext(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard tabManager.selectedTabId != nil else { + return .err(code: "not_found", message: "No workspace selected", data: nil) + } if let windowId = v2ResolveWindowId(tabManager: tabManager) { _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) setActiveTabManager(tabManager) } tabManager.selectNextTab() - guard let workspaceId = tabManager.selectedTabId else { return } + guard let workspaceId = tabManager.selectedTabId else { + return .err(code: "not_found", message: "No workspace selected", data: nil) + } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - return result } - func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) - v2MainSync { - guard tabManager.selectedTabId != nil else { return } + nonisolated func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard tabManager.selectedTabId != nil else { + return .err(code: "not_found", message: "No workspace selected", data: nil) + } if let windowId = v2ResolveWindowId(tabManager: tabManager) { _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) setActiveTabManager(tabManager) } tabManager.selectPreviousTab() - guard let workspaceId = tabManager.selectedTabId else { return } + guard let workspaceId = tabManager.selectedTabId else { + return .err(code: "not_found", message: "No workspace selected", data: nil) + } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - return result } - func v2WorkspaceLast(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) - v2MainSync { - guard let before = tabManager.selectedTabId else { return } + nonisolated func v2WorkspaceLast(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let before = tabManager.selectedTabId else { + return .err(code: "not_found", message: "No previous workspace in history", data: nil) + } if let windowId = v2ResolveWindowId(tabManager: tabManager) { _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) setActiveTabManager(tabManager) } tabManager.navigateBack() - guard let after = tabManager.selectedTabId, after != before else { return } + guard let after = tabManager.selectedTabId, after != before else { + return .err(code: "not_found", message: "No previous workspace in history", data: nil) + } let windowId = v2ResolveWindowId(tabManager: tabManager) - result = .ok([ + return .ok([ "workspace_id": after.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: after), "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId) ]) } - return result } - func v2WorkspaceEqualizeSplits(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - let orientationFilter = v2String(params, "orientation") - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + nonisolated func v2WorkspaceEqualizeSplits(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let orientationFilter = v2String(params, "orientation") + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } let tree = ws.bonsplitController.treeSnapshot() let success = v2ProportionalEqualize(node: tree, controller: ws.bonsplitController, orientationFilter: orientationFilter) - result = .ok([ + return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "equalized": success ]) } - return result } /// Count leaf panes in a tree node. @@ -482,89 +454,86 @@ extension TerminalController { return didEqualize || l || r } - func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { + nonisolated func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { return v2InvalidParam("workspace_id") } - 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) - } + 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) + 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 } - 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) + // 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 } - 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) + 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 } - 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) + 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: "|"))" - ) + 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 - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. - v2MainSync { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) } let config = WorkspaceRemoteConfiguration( @@ -583,7 +552,7 @@ extension TerminalController { workspace.configureRemoteConnection(config, autoConnect: autoConnect) let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -591,37 +560,31 @@ extension TerminalController { "remote": workspace.remoteStatusPayload(), ]) } - - return result } - func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { + 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 fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - let clearConfiguration = v2Bool(params, "clear") ?? false - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. - v2MainSync { + 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 + 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) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -629,44 +592,37 @@ extension TerminalController { "remote": workspace.remoteStatusPayload(), ]) } - - return result } - func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { + nonisolated func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { return v2InvalidParam("workspace_id") } - 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) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. - v2MainSync { + 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 + 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 { - result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ + return .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ "workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), ]) - return } workspace.reconnectRemoteConnection() let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -674,38 +630,32 @@ extension TerminalController { "remote": workspace.remoteStatusPayload(), ]) } - - return result } - func v2WorkspaceRemoteForegroundAuthReady(params: [String: Any]) -> V2CallResult { + 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 fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? .trimmingCharacters(in: .whitespacesAndNewlines) - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because this may arm a pending connect or start reconnecting immediately. - v2MainSync { + 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 + 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) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -713,34 +663,28 @@ extension TerminalController { "remote": workspace.remoteStatusPayload(), ]) } - - return result } - func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { + nonisolated func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { let requestedWorkspaceId = v2UUID(params, "workspace_id") if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { return v2InvalidParam("workspace_id") } - 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) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. - v2MainSync { + 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 + 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) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -748,11 +692,9 @@ extension TerminalController { "remote": workspace.remoteStatusPayload(), ]) } - - return result } - func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + nonisolated func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { guard let workspaceId = v2UUID(params, "workspace_id") else { return v2InvalidParam("workspace_id") } @@ -765,22 +707,20 @@ extension TerminalController { return v2InvalidParam("relay_port") } - var result: V2CallResult = .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, - ]) - - v2MainSync { + return v2MainSync { guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return + 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) - result = .ok([ + return .ok([ "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": workspace.id.uuidString, @@ -791,8 +731,6 @@ extension TerminalController { "remote": workspace.remoteStatusPayload(), ]) } - - return result } // `surface.report_tty` and `surface.ports_kick` are high-frequency telemetry commands (see @@ -802,34 +740,34 @@ extension TerminalController { // `workspace.report_meta_block`) — surface resolution and the model mutation happen entirely // inside the async block, and the JSON-RPC response is an optimistic `ok` echoing the request // params, not the value resolved on main. Refs #82. - func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let action = v2ActionKey(params) else { - return .err(code: "invalid_params", message: "Missing action", data: nil) - } - - let supportedActions = [ - "pin", "unpin", "rename", "clear_name", - "set_description", "clear_description", - "move_up", "move_down", "move_top", - "close_others", "close_above", "close_below", - "mark_read", "mark_unread", - "set_color", "clear_color" - ] + nonisolated func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let action = v2ActionKey(params) else { + return .err(code: "invalid_params", message: "Missing action", data: nil) + } - var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [ - "action": action, - "supported_actions": supportedActions - ]) + let supportedActions = [ + "pin", "unpin", "rename", "clear_name", + "set_description", "clear_description", + "move_up", "move_down", "move_top", + "close_others", "close_above", "close_below", + "mark_read", "mark_unread", + "set_color", "clear_color" + ] + + var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [ + "action": action, + "supported_actions": supportedActions + ]) - v2MainSync { let requestedWorkspaceId = v2UUID(params, "workspace_id") ?? tabManager.selectedTabId guard let workspaceId = requestedWorkspaceId, let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return result } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -876,7 +814,7 @@ extension TerminalController { guard let titleRaw = v2String(params, "title"), !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { result = v2InvalidParam("title") - return + return result } let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) tabManager.setCustomTitle(tabId: workspace.id, title: title) @@ -890,7 +828,7 @@ extension TerminalController { guard let descriptionRaw = v2String(params, "description"), !descriptionRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { result = v2InvalidParam("description") - return + return result } tabManager.setCustomDescription(tabId: workspace.id, description: descriptionRaw) finish(["description": v2OrNull(workspace.customDescription)]) @@ -902,7 +840,7 @@ extension TerminalController { case "move_up": guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return result } _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0)) finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) @@ -910,7 +848,7 @@ extension TerminalController { case "move_down": guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return result } _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1)) finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) @@ -927,7 +865,7 @@ extension TerminalController { case "close_above": guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return result } let candidates = Array(tabManager.tabs.prefix(index)).filter { !$0.isPinned } let closed = closeWorkspaces(candidates) @@ -936,7 +874,7 @@ extension TerminalController { case "close_below": guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return result } let candidates: [Workspace] if index + 1 < tabManager.tabs.count { @@ -959,7 +897,7 @@ extension TerminalController { guard let colorRaw = v2String(params, "color"), !colorRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { result = v2InvalidParam("color") - return + return result } let colorInput = colorRaw.trimmingCharacters(in: .whitespacesAndNewlines) // Resolve named colors from the effective palette, including file-defined additions. @@ -976,7 +914,7 @@ extension TerminalController { result = .err(code: "invalid_params", message: "Invalid color. Use a hex value (#RRGGBB) or a named color.", data: [ "named_colors": colorNames ]) - return + return result } tabManager.setTabColor(tabId: workspace.id, color: hex) finish(["color": hex]) @@ -991,42 +929,41 @@ extension TerminalController { "supported_actions": supportedActions ]) } + return result } - - return result } - func v2TabAction(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let action = v2ActionKey(params) else { - return .err(code: "invalid_params", message: "Missing action", data: nil) - } - - let supportedActions = [ - "rename", "clear_name", - "close_left", "close_right", "close_others", - "new_terminal_right", "new_browser_right", - "reload", "duplicate", - "pin", "unpin", "mark_read", "mark_unread" - ] + nonisolated func v2TabAction(params: [String: Any]) -> V2CallResult { + return v2MainSync { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let action = v2ActionKey(params) else { + return .err(code: "invalid_params", message: "Missing action", data: nil) + } - var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ - "action": action, - "supported_actions": supportedActions - ]) + let supportedActions = [ + "rename", "clear_name", + "close_left", "close_right", "close_others", + "new_terminal_right", "new_browser_right", + "reload", "duplicate", + "pin", "unpin", "mark_read", "mark_unread" + ] + + var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [ + "action": action, + "supported_actions": supportedActions + ]) - v2MainSync { guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) - return + return result } let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { result = .err(code: "not_found", message: "No focused tab", data: nil) - return + return result } guard workspace.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Tab not found", data: [ @@ -1035,7 +972,7 @@ extension TerminalController { "tab_id": surfaceId.uuidString, "tab_ref": v2TabRef(uuid: surfaceId) ]) - return + return result } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -1105,7 +1042,7 @@ extension TerminalController { guard let titleRaw = v2String(params, "title"), !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { result = v2InvalidParam("title") - return + return result } let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) workspace.setPanelCustomTitle(panelId: surfaceId, title: title) @@ -1134,7 +1071,7 @@ extension TerminalController { case "reload", "reload_tab": guard let browserPanel = workspace.browserPanel(for: surfaceId) else { result = .err(code: "invalid_state", message: "Reload is only available for browser tabs", data: nil) - return + return result } browserPanel.reload() finish() @@ -1144,7 +1081,7 @@ extension TerminalController { let paneId = workspace.paneId(forPanelId: surfaceId), let browserPanel = workspace.browserPanel(for: surfaceId) else { result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil) - return + return result } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) @@ -1154,7 +1091,7 @@ extension TerminalController { focus: true ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) - return + return result } _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex) finish([ @@ -1168,13 +1105,13 @@ extension TerminalController { guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), let paneId = workspace.paneId(forPanelId: surfaceId) else { result = .err(code: "not_found", message: "Tab pane not found", data: nil) - return + return result } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) - return + return result } _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex) finish([ @@ -1188,20 +1125,20 @@ extension TerminalController { guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), let paneId = workspace.paneId(forPanelId: surfaceId) else { result = .err(code: "not_found", message: "Tab pane not found", data: nil) - return + return result } let urlRaw = v2String(params, "url") let url = urlRaw.flatMap { URL(string: $0) } if urlRaw != nil && url == nil { result = .err(code: "invalid_params", message: "Invalid URL", data: ["url": v2OrNull(urlRaw)]) - return + return result } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) - return + return result } _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex) finish([ @@ -1215,12 +1152,12 @@ extension TerminalController { guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), let paneId = workspace.paneId(forPanelId: surfaceId) else { result = .err(code: "not_found", message: "Tab pane not found", data: nil) - return + return result } let tabs = workspace.bonsplitController.tabs(inPane: paneId) guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { result = .err(code: "not_found", message: "Tab not found in pane", data: nil) - return + return result } let targetIds = Array(tabs.prefix(index).map(\.id)) let closeResult = closeTabs(targetIds) @@ -1230,12 +1167,12 @@ extension TerminalController { guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), let paneId = workspace.paneId(forPanelId: surfaceId) else { result = .err(code: "not_found", message: "Tab pane not found", data: nil) - return + return result } let tabs = workspace.bonsplitController.tabs(inPane: paneId) guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else { result = .err(code: "not_found", message: "Tab not found in pane", data: nil) - return + return result } let targetIds = (index + 1 < tabs.count) ? Array(tabs.suffix(from: index + 1).map(\.id)) : [] let closeResult = closeTabs(targetIds) @@ -1245,7 +1182,7 @@ extension TerminalController { guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId), let paneId = workspace.paneId(forPanelId: surfaceId) else { result = .err(code: "not_found", message: "Tab pane not found", data: nil) - return + return result } let targetIds = workspace.bonsplitController.tabs(inPane: paneId) .map(\.id) @@ -1259,8 +1196,7 @@ extension TerminalController { "supported_actions": supportedActions ]) } + return result } - - return result } } diff --git a/Sources/TerminalController+Worktree.swift b/Sources/TerminalController+Worktree.swift index 028d3c8b..e9c649cb 100644 --- a/Sources/TerminalController+Worktree.swift +++ b/Sources/TerminalController+Worktree.swift @@ -94,8 +94,8 @@ extension TerminalController { ]) } - func v2WorktreeOpen(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { + nonisolated func v2WorktreeOpen(params: [String: Any]) -> V2CallResult { + guard let windowId = v2ResolveWorktreeWindowId(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } guard let repoRoot = v2ResolveWorktreeRepoRoot(params: params) else { @@ -117,39 +117,41 @@ extension TerminalController { return .err(code: "worktree_not_found", message: "No matching worktree found", data: nil) } - if let existingWorkspace = v2MainSync({ self.v2WorktreeOpenWorkspace(tabManager: tabManager, path: entry.path) }) { - // "already open" is idempotent -- only touch focus/selection when the caller - // explicitly opted in via `focus: true` (socket focus policy default is false). - if v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) { - v2MainSync { - if let windowId = self.v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - self.setActiveTabManager(tabManager) - } + let focusRequested = v2Bool(params, "focus") ?? false + return v2MainSync { + guard let tabManager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + if let existingWorkspace = self.v2WorktreeOpenWorkspace(tabManager: tabManager, path: entry.path) { + // "already open" is idempotent -- only touch focus/selection when the caller + // explicitly opted in via `focus: true` (socket focus policy default is false). + if self.v2FocusAllowed(requested: focusRequested) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + self.setActiveTabManager(tabManager) tabManager.selectWorkspace(existingWorkspace) } + return .ok([ + "worktree": ["path": entry.path, "branch": self.v2OrNull(entry.branch), "repo": repoRoot], + "workspace_id": existingWorkspace.id.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: existingWorkspace.id), + "window_id": self.v2OrNull(windowId.uuidString), + "window_ref": self.v2Ref(kind: .window, uuid: windowId) + ]) } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "worktree": ["path": entry.path, "branch": v2OrNull(entry.branch), "repo": repoRoot], - "workspace_id": existingWorkspace.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: existingWorkspace.id), - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId) - ]) - } - return v2CompleteWorktreeCreation( - tabManager: tabManager, - entry: entry, - repoRoot: repoRoot, - layoutName: nil, - focusRequested: v2Bool(params, "focus") ?? false - ) + return self.v2CompleteWorktreeCreation( + tabManager: tabManager, + entry: entry, + repoRoot: repoRoot, + layoutName: nil, + focusRequested: focusRequested + ) + } } - func v2WorktreeRemove(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { + nonisolated func v2WorktreeRemove(params: [String: Any]) -> V2CallResult { + guard let windowId = v2ResolveWorktreeWindowId(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } guard let repoRoot = v2ResolveWorktreeRepoRoot(params: params) else { @@ -173,12 +175,15 @@ extension TerminalController { switch GitWorktreeManager.remove(repoRoot: repoRoot, path: entry.path, force: v2Bool(params, "force") ?? false) { case .success: - var closedWorkspaceId: UUID? - v2MainSync { + let closedWorkspaceId = v2MainSync { () -> UUID? in + guard let tabManager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { + return nil + } if let ws = self.v2WorktreeOpenWorkspace(tabManager: tabManager, path: entry.path) { tabManager.closeWorkspace(ws) - closedWorkspaceId = ws.id + return ws.id } + return nil } var result: [String: Any] = ["removed": true] if let closedWorkspaceId { @@ -196,8 +201,8 @@ extension TerminalController { } } - func v2WorktreeList(params: [String: Any]) -> V2CallResult { - let tabManager = v2ResolveTabManager(params: params) + nonisolated func v2WorktreeList(params: [String: Any]) -> V2CallResult { + let windowId = v2ResolveWorktreeWindowId(params: params) guard let repoRoot = v2ResolveWorktreeRepoRoot(params: params) else { return .err(code: "not_a_git_repo", message: "Could not resolve a git repository from 'repo'", data: nil) } @@ -205,6 +210,23 @@ extension TerminalController { return .err(code: "not_a_git_repo", message: "'\(repoRoot)' is not a git repository", data: nil) } + let homeDirectory = FileManager.default.homeDirectoryForCurrentUser.path + let openWorkspaceIdsByPath = v2MainSync { () -> [String: UUID] in + guard let windowId, + let tabManager = AppDelegate.shared?.tabManagerFor(windowId: windowId) else { + return [:] + } + var workspaceIdsByPath: [String: UUID] = [:] + for workspace in tabManager.tabs { + guard let key = SidebarBranchOrdering.canonicalDirectoryKey( + workspace.currentDirectory, + homeDirectoryForTildeExpansion: homeDirectory + ), workspaceIdsByPath[key] == nil else { continue } + workspaceIdsByPath[key] = workspace.id + } + return workspaceIdsByPath + } + var payloads: [[String: Any]] = [] for entry in entries where !entry.isBare { var payload: [String: Any] = [ @@ -213,11 +235,13 @@ extension TerminalController { "head": v2OrNull(entry.headSHA), "is_open": false ] - if let tabManager, - let ws = v2MainSync({ self.v2WorktreeOpenWorkspace(tabManager: tabManager, path: entry.path) }) { + if let key = SidebarBranchOrdering.canonicalDirectoryKey( + entry.path, + homeDirectoryForTildeExpansion: homeDirectory + ), let workspaceId = openWorkspaceIdsByPath[key] { payload["is_open"] = true - payload["workspace_id"] = ws.id.uuidString - payload["workspace_ref"] = v2Ref(kind: .workspace, uuid: ws.id) + payload["workspace_id"] = workspaceId.uuidString + payload["workspace_ref"] = v2Ref(kind: .workspace, uuid: workspaceId) } payloads.append(payload) } @@ -227,7 +251,14 @@ extension TerminalController { // MARK: - Shared helpers - private func v2ExpandedPath(_ raw: String) -> String { + private nonisolated func v2ResolveWorktreeWindowId(params: [String: Any]) -> UUID? { + v2MainSync { + guard let tabManager = self.v2ResolveTabManager(params: params) else { return nil } + return AppDelegate.shared?.windowId(for: tabManager) + } + } + + private nonisolated func v2ExpandedPath(_ raw: String) -> String { (raw as NSString).expandingTildeInPath } @@ -235,7 +266,7 @@ extension TerminalController { /// callers can pass any directory inside the repo, not only its exact toplevel. Missing or /// unresolvable `repo` fails clearly rather than falling back to the app process's own /// (meaningless, from the caller's perspective) working directory -- see plan risk #2. - private func v2ResolveWorktreeRepoRoot(params: [String: Any]) -> String? { + private nonisolated func v2ResolveWorktreeRepoRoot(params: [String: Any]) -> String? { guard let raw = v2String(params, "repo") else { return nil } return GitWorktreeManager.resolveRepoRoot(from: v2ExpandedPath(raw)) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0bd0eaf5..105e3e60 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -49,10 +49,13 @@ class TerminalController { private nonisolated(unsafe) var pendingAcceptLoopRearmGeneration: UInt64? private nonisolated(unsafe) var pendingAcceptLoopResumeGeneration: UInt64? private nonisolated(unsafe) var listenerStartInProgress = false + private nonisolated(unsafe) var clientAuthorizationGeneration: UInt64 = 0 + private nonisolated(unsafe) var authCredentialEpoch: UInt64 = 0 + private nonisolated(unsafe) var registeredUnixClientFDs: Set = [] + private nonisolated(unsafe) var socketPasswordCredentialSource = SocketPasswordCredentialSource.live private nonisolated let listenerStateLock = NSLock() - private var clientHandlers: [Int32: Thread] = [:] var tabManager: TabManager? - private var accessMode: SocketControlMode = .cmuxOnly + private nonisolated(unsafe) var accessMode: SocketControlMode = .cmuxOnly private let myPid = getpid() private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0 private nonisolated static let socketCommandPolicyLock = NSLock() @@ -76,6 +79,7 @@ class TerminalController { private struct ListenerStateSnapshot { let socketPath: String + let accessMode: SocketControlMode let serverSocket: Int32 let isRunning: Bool let acceptLoopAlive: Bool @@ -85,6 +89,41 @@ class TerminalController { let listenerStartInProgress: Bool } + struct SocketRequestPolicy: Sendable { + let socketPath: String + let accessMode: SocketControlMode + let requiresPasswordAuthentication: Bool + } + + struct SocketPasswordCredentialSource: Sendable { + let hasConfiguredPassword: @Sendable () -> Bool + let verify: @Sendable (String) -> Bool + + static let live = SocketPasswordCredentialSource( + hasConfiguredPassword: { + SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) + }, + verify: { password in + SocketControlPasswordStore.verify( + password: password, + allowLazyKeychainFallback: true + ) + } + ) + } + + struct UnixClientPolicy: Sendable { + let clientGeneration: UInt64 + let authEpoch: UInt64 + let request: SocketRequestPolicy + } + + enum SocketConnectionSource: Sendable { + case unix(UnixClientPolicy) + case rejectedUnix + case mobileBridge + } + enum AcceptFailureRecoveryAction: Equatable { case retryImmediately case resumeAfterDelay(delayMs: Int) @@ -137,34 +176,92 @@ class TerminalController { "debug.app.activate" ] - enum V2HandleKind: String, CaseIterable { + enum V2HandleKind: String, CaseIterable, Sendable { case window case workspace case pane case surface } - private var v2NextHandleOrdinal: [V2HandleKind: Int] = [ - .window: 1, - .workspace: 1, - .pane: 1, - .surface: 1, - ] - // Socket v2 commands execute from detached threads; these mappings are shared across - // commands and must be serialized to avoid concurrent mutation crashes. - private let v2HandleRefStateLock = NSLock() - private var v2RefByUUID: [V2HandleKind: [UUID: String]] = [ - .window: [:], - .workspace: [:], - .pane: [:], - .surface: [:], - ] - private var v2UUIDByRef: [V2HandleKind: [String: UUID]] = [ - .window: [:], - .workspace: [:], - .pane: [:], - .surface: [:], - ] + private final class V2HandleRefStore: @unchecked Sendable { + private let lock = NSLock() + private var nextOrdinal: [V2HandleKind: Int] = [ + .window: 1, + .workspace: 1, + .pane: 1, + .surface: 1, + ] + private var refByUUID: [V2HandleKind: [UUID: String]] = [ + .window: [:], + .workspace: [:], + .pane: [:], + .surface: [:], + ] + private var uuidByRef: [V2HandleKind: [String: UUID]] = [ + .window: [:], + .workspace: [:], + .pane: [:], + .surface: [:], + ] + + func ensure(kind: V2HandleKind, uuid: UUID) -> String { + lock.lock() + defer { lock.unlock() } + + if let existing = refByUUID[kind]?[uuid] { + return existing + } + let next = nextOrdinal[kind] ?? 1 + let ref = "\(kind.rawValue):\(next)" + var byUUID = refByUUID[kind] ?? [:] + var byRef = uuidByRef[kind] ?? [:] + byUUID[uuid] = ref + byRef[ref] = uuid + refByUUID[kind] = byUUID + uuidByRef[kind] = byRef + nextOrdinal[kind] = next + 1 + return ref + } + + func knownUUID(forHandle handle: String) -> UUID? { + lock.lock() + defer { lock.unlock() } + + for kind in V2HandleKind.allCases { + if let id = uuidByRef[kind]?[handle] { + return id + } + } + // Tab refs are aliases for surface refs in tab-facing APIs. + let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("tab:"), + let ordinal = Int(trimmed.replacingOccurrences(of: "tab:", with: "")), + let id = uuidByRef[.surface]?["surface:\(ordinal)"] { + return id + } + return nil + } + + func prune(liveByKind: [V2HandleKind: Set]) { + lock.lock() + defer { lock.unlock() } + + for kind in V2HandleKind.allCases { + guard let live = liveByKind[kind], var byUUID = refByUUID[kind] else { continue } + var byRef = uuidByRef[kind] ?? [:] + for (uuid, ref) in byUUID where !live.contains(uuid) { + byUUID.removeValue(forKey: uuid) + byRef.removeValue(forKey: ref) + } + refByUUID[kind] = byUUID + uuidByRef[kind] = byRef + } + } + } + + // Socket v2 commands execute from detached threads, so the store owns both the mappings + // and the lock that serializes their mutation. + private nonisolated let v2HandleRefStore = V2HandleRefStore() struct V2BrowserElementRefEntry { let surfaceId: UUID @@ -176,16 +273,69 @@ class TerminalController { let navigationGeneration: UInt64 } + struct V2BrowserElementRefCapacity { + let limit: Int + let requestedUnique: Int + let remaining: Int + let selectorByteLimit: Int + let byteLimit: Int + let requestedBytes: Int + let remainingBytes: Int + } + + enum V2BrowserElementRefAllocation { + case allocated([String]) + case resourceExhausted(V2BrowserElementRefCapacity) + } + + struct V2BrowserSnapshotContent { + let title: String + let url: String + let entries: [[String: Any]] + let text: String + let html: String + let metadata: [String: Any] + } + final class V2BrowserUndefinedSentinel {} + enum V2BrowserDownloadEventWaitOutcome { + case event([String: Any], droppedEvents: Int) + case timedOut + case cancelled + case busy + } + + struct V2BrowserDownloadEventWaiter { + let surfaceId: UUID + let id: UUID + let finish: (V2BrowserDownloadEventWaitOutcome) -> Void + } + static let v2BrowserEvalEnvelopeTypeKey = "__programa_t" static let v2BrowserEvalEnvelopeValueKey = "__programa_v" static let v2BrowserEvalEnvelopeTypeUndefined = "undefined" static let v2BrowserEvalEnvelopeTypeValue = "value" + static let v2BrowserElementRefLimit = 4_096 static let v2BrowserElementRefSelectorByteLimit = 16_384 + static let v2BrowserElementRefByteLimit = 4_194_304 + static let v2BrowserDownloadEventQueueLimit = 256 + static let v2BrowserSnapshotNodeVisitLimit = 4_096 + static let v2BrowserSnapshotEntryLimit = 256 + static let v2BrowserSnapshotMaxDepth = 64 + static let v2BrowserSnapshotNameByteLimit = 1_024 + static let v2BrowserSnapshotRoleByteLimit = 64 + static let v2BrowserSnapshotEntryByteLimit = 262_144 + static let v2BrowserSnapshotTitleByteLimit = 1_024 + static let v2BrowserSnapshotURLByteLimit = 16_384 + static let v2BrowserSnapshotTextCharacterLimit = 262_144 + static let v2BrowserSnapshotHTMLCharacterLimit = 1_048_576 var v2BrowserNextElementOrdinal: Int = 1 var v2BrowserElementRefs: [String: V2BrowserElementRefEntry] = [:] + var v2BrowserElementRefTokensBySurface: [UUID: Set] = [:] + var v2BrowserElementRefBySelectorBySurface: [UUID: [String: String]] = [:] + var v2BrowserElementRefBytesBySurface: [UUID: Int] = [:] var v2BrowserFrameSelectorBySurface: [UUID: String] = [:] /// Bumped on every committed main-frame navigation of a browser surface. Element refs /// (`v2BrowserElementRefs`) capture the generation at allocation time so a ref from a @@ -195,9 +345,15 @@ class TerminalController { var v2BrowserInitScriptsBySurface: [UUID: [String]] = [:] var v2BrowserInitStylesBySurface: [UUID: [String]] = [:] var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] + var v2BrowserDownloadDroppedEventCountBySurface: [UUID: Int] = [:] + // SHORTCUT: one process-wide event-mode download waiter avoids nested CFRunLoop waits. + // ceiling: concurrent browser.download.wait event calls return busy. + // upgrade: move socket command waiting to async continuations, then use per-surface waiter queues. + var v2BrowserPendingDownloadEventWaiter: V2BrowserDownloadEventWaiter? var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] var v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() private var browserDownloadObserver: NSObjectProtocol? + private var socketControlPasswordObserver: NSObjectProtocol? private init() { browserDownloadObserver = NotificationCenter.default.addObserver( @@ -207,14 +363,19 @@ class TerminalController { ) { [weak self] note in guard let surfaceId = note.userInfo?["surfaceId"] as? UUID, let event = note.userInfo?["event"] as? [String: Any] else { return } - Task { @MainActor [weak self] in - guard let self else { return } - var queue = self.v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - queue.append(event) - self.v2BrowserDownloadEventsBySurface[surfaceId] = queue + MainActor.assumeIsolated { + self?.v2BrowserEnqueueDownloadEvent(surfaceId: surfaceId, event: event) } } + socketControlPasswordObserver = NotificationCenter.default.addObserver( + forName: SocketControlPasswordStore.didChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.socketControlPasswordDidChange() + } + // Wire the session-WAL periodic frame capture's main-thread/AppKit // VT export in. `SessionWALStore` decides *when* to capture (cadence, // idle-skip, offset bookkeeping) but never touches AppKit itself, so @@ -240,6 +401,7 @@ class TerminalController { withListenerState { ListenerStateSnapshot( socketPath: socketPath, + accessMode: accessMode, serverSocket: serverSocket, isRunning: isRunning, acceptLoopAlive: acceptLoopAlive, @@ -251,6 +413,85 @@ class TerminalController { } } + private nonisolated func invalidateUnixClientsLocked() { + clientAuthorizationGeneration &+= 1 + for clientSocket in registeredUnixClientFDs { + _ = shutdown(clientSocket, SHUT_RDWR) + } + } + + private nonisolated func registerUnixClient( + _ clientSocket: Int32, + acceptLoopGeneration: UInt64 + ) -> UnixClientPolicy? { + withListenerState { + guard isRunning, acceptLoopGeneration == activeAcceptLoopGeneration else { + return nil + } + registeredUnixClientFDs.insert(clientSocket) + return UnixClientPolicy( + clientGeneration: clientAuthorizationGeneration, + authEpoch: authCredentialEpoch, + request: SocketRequestPolicy( + socketPath: socketPath, + accessMode: accessMode, + requiresPasswordAuthentication: accessMode.requiresPasswordAuth + ) + ) + } + } + + private nonisolated func unregisterUnixClient(_ clientSocket: Int32) { + withListenerState { + _ = registeredUnixClientFDs.remove(clientSocket) + } + } + + private nonisolated func isUnixClientPolicyCurrent( + _ policy: UnixClientPolicy, + clientSocket: Int32 + ) -> Bool { + withListenerState { + guard isRunning, + registeredUnixClientFDs.contains(clientSocket), + policy.clientGeneration == clientAuthorizationGeneration, + policy.request.socketPath == socketPath, + policy.request.accessMode == accessMode else { + return false + } + return !policy.request.requiresPasswordAuthentication || policy.authEpoch == authCredentialEpoch + } + } + + private nonisolated func mobileBridgeRequestPolicy() -> SocketRequestPolicy { + withListenerState { + SocketRequestPolicy( + socketPath: socketPath, + accessMode: accessMode, + requiresPasswordAuthentication: false + ) + } + } + + private nonisolated func socketControlPasswordDidChange() { + withListenerState { + authCredentialEpoch &+= 1 + if accessMode.requiresPasswordAuth { + invalidateUnixClientsLocked() + } + } + } + + #if DEBUG + nonisolated func setSocketPasswordCredentialSourceForTesting( + _ source: SocketPasswordCredentialSource? + ) { + withListenerState { + socketPasswordCredentialSource = source ?? .live + } + } + #endif + nonisolated func activeSocketPath(preferredPath: String) -> String { let snapshot = listenerStateSnapshot() if snapshot.isRunning || snapshot.acceptLoopAlive || snapshot.listenerStartInProgress || snapshot.serverSocket >= 0 { @@ -275,11 +516,11 @@ class TerminalController { (Thread.current.threadDictionary[socketCommandFocusAllowanceThreadKey] as? NSNumber)?.boolValue ?? false } - func socketCommandAllowsInAppFocusMutations() -> Bool { + nonisolated func socketCommandAllowsInAppFocusMutations() -> Bool { Self.socketCommandAllowsInAppFocusMutations() } - func v2FocusAllowed(requested: Bool = true) -> Bool { + nonisolated func v2FocusAllowed(requested: Bool = true) -> Bool { requested && socketCommandAllowsInAppFocusMutations() } @@ -514,7 +755,7 @@ class TerminalController { } } - static let socketFastPathState = SocketFastPathState() + nonisolated static let socketFastPathState = SocketFastPathState() nonisolated static func explicitSocketScope( options: [String: String] ) -> (workspaceId: UUID, panelId: UUID)? { @@ -638,7 +879,7 @@ class TerminalController { /// /// Entries are added when a surface is revived and removed when it is torn /// down, so a recycled pid cannot stay authorized past its session. - private static let revivedRootsLock = NSLock() + private nonisolated static let revivedRootsLock = NSLock() private nonisolated(unsafe) static var revivedRoots: Set = [] nonisolated static func registerRevivedRoot(_ pid: pid_t) { @@ -890,30 +1131,33 @@ class TerminalController { func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) { self.tabManager = tabManager - self.accessMode = accessMode // Screen-manifest agent detection (docs/plans/screen-manifest-detection.md): lazily // starts its own background sampling thread, independent of the socket listener below -- // this is simply a convenient, always-reached app-bootstrap point to kick it off once. AgentScreenDetectionEngine.shared.startIfNeeded() - let existing = withListenerState { - (isRunning: isRunning, socketPath: self.socketPath, acceptLoopAlive: acceptLoopAlive) + let reusedListener = withListenerState { + isRunning + && self.socketPath == socketPath + && self.accessMode == accessMode + && acceptLoopAlive } - if existing.isRunning && existing.socketPath == socketPath && existing.acceptLoopAlive { - self.accessMode = accessMode + if reusedListener { applySocketPermissions() return } - if existing.isRunning { + if withListenerState({ isRunning }) { stop() } var activeSocketPath = socketPath withListenerState { + invalidateUnixClientsLocked() self.socketPath = activeSocketPath + self.accessMode = accessMode listenerStartInProgress = true } var listenerActivated = false @@ -1148,6 +1392,7 @@ class TerminalController { listenerStartInProgress = false nextAcceptLoopGeneration &+= 1 activeAcceptLoopGeneration = 0 + invalidateUnixClientsLocked() let socketToClose = serverSocket serverSocket = -1 return (socketToClose, socketPath) @@ -1174,8 +1419,9 @@ class TerminalController { } private func applySocketPermissions() { - let permissions = mode_t(accessMode.socketFilePermissions) - let currentSocketPath = withListenerState { socketPath } + let (currentSocketPath, permissions) = withListenerState { + (socketPath, mode_t(accessMode.socketFilePermissions)) + } if chmod(currentSocketPath, permissions) != 0 { print( "TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(currentSocketPath)" @@ -1212,7 +1458,11 @@ class TerminalController { return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password") } - guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else { + let credentialSource = withListenerState { + socketPasswordCredentialSource + } + + guard credentialSource.hasConfiguredPassword() else { return v2Error( id: id, code: "auth_unconfigured", @@ -1220,15 +1470,19 @@ class TerminalController { ) } - guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else { + guard credentialSource.verify(provided) else { return v2Error(id: id, code: "auth_failed", message: "Invalid password") } authenticated = true return v2Ok(id: id, result: ["authenticated": true]) } - private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? { - guard accessMode.requiresPasswordAuth else { + private func authResponseIfNeeded( + for command: String, + authenticated: inout Bool, + requestPolicy: SocketRequestPolicy + ) -> String? { + guard requestPolicy.requiresPasswordAuthentication else { return nil } if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) { @@ -1281,6 +1535,7 @@ class TerminalController { isRunning = false activeAcceptLoopGeneration = 0 pendingAcceptLoopResumeGeneration = nil + invalidateUnixClientsLocked() var socketToClose: Int32 = -1 var pathToUnlink: String? @@ -1380,10 +1635,16 @@ class TerminalController { // ncat --send-only closes the connection right after writing, so by // the time a new thread starts the peer may already be gone. let peerPid = getPeerPid(clientSocket) + let connectionSource: SocketConnectionSource + if let policy = registerUnixClient(clientSocket, acceptLoopGeneration: generation) { + connectionSource = .unix(policy) + } else { + connectionSource = .rejectedUnix + } // Handle client in new thread - Thread.detachNewThread { [weak self] in - self?.handleClient(clientSocket, peerPid: peerPid) + Thread.detachNewThread { [self] in + handleClient(clientSocket, peerPid: peerPid, source: connectionSource) } } } @@ -1426,27 +1687,21 @@ class TerminalController { DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in guard let self else { return } guard let tabManager = self.tabManager else { return } - guard let restartPath = self.withListenerState({ () -> String? in + guard let restartPolicy = self.withListenerState({ () -> (path: String, mode: SocketControlMode)? in guard self.pendingAcceptLoopRearmGeneration == generation else { return nil } self.pendingAcceptLoopRearmGeneration = nil - return self.socketPath + return (self.socketPath, self.accessMode) }) else { return } - let restartMode = self.accessMode - - dilog("socket.accept", "rearm executing path=\(restartPath)") + dilog("socket.accept", "rearm executing path=\(restartPolicy.path)") self.stop() - self.start(tabManager: tabManager, socketPath: restartPath, accessMode: restartMode) + self.start(tabManager: tabManager, socketPath: restartPolicy.path, accessMode: restartPolicy.mode) } } - /// `ignoresListenerState: true` decouples this connection's lifetime from - /// the Unix-socket listener. The mobile bridge feeds sessions in over a - /// socketpair and is gated independently by `MobileBridgeMode`, so a user - /// who sets Socket Control Mode to Off must not silently break their paired - /// phone -- previously the read loop exited immediately and the phone - /// connected to a session that accepted nothing. - func handleClient(_ socket: Int32, peerPid: pid_t? = nil, ignoresListenerState: Bool = false) { + /// Unix clients carry the listener policy captured when they were accepted. + /// Mobile Bridge sessions are admitted independently by pairing and its method allow-list. + func handleClient(_ socket: Int32, peerPid: pid_t? = nil, source: SocketConnectionSource) { // Owns this connection's writes (both ordinary v2 responses and any #167 subscription // event pushes) and its subscription lifecycle. `teardown()` (which tears down any // attached subscription) must run before the fd is closed -- defers unwind LIFO, so the @@ -1465,9 +1720,33 @@ class TerminalController { dilog("socket.conn", "close pid=\(peerPidDescription) reason=\(closeReason) durationMs=\(durationMs)") } + let requestPolicy: SocketRequestPolicy + let unixPolicy: UnixClientPolicy? + switch source { + case .unix(let policy): + requestPolicy = policy.request + unixPolicy = policy + case .rejectedUnix: + closeReason = "listener_stopped" + return + case .mobileBridge: + requestPolicy = mobileBridgeRequestPolicy() + unixPolicy = nil + } + defer { + if unixPolicy != nil { + unregisterUnixClient(socket) + } + } + + if let unixPolicy, !isUnixClientPolicyCurrent(unixPolicy, clientSocket: socket) { + closeReason = "policy_revoked" + return + } + // In cmuxOnly mode, verify the connecting process is a descendant of cmux. // In allowAll mode (env-var only), skip the ancestry check. - if accessMode == .cmuxOnly { + if unixPolicy != nil, requestPolicy.accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. let pid = peerPid ?? getPeerPid(socket) @@ -1504,7 +1783,7 @@ class TerminalController { var pending = Data() var authenticated = false - connectionLoop: while ignoresListenerState || withListenerState({ isRunning }) { + connectionLoop: while true { let bytesRead = read(socket, &buffer, buffer.count - 1) if bytesRead <= 0 { if bytesRead == 0 { @@ -1521,6 +1800,11 @@ class TerminalController { let lineData = Data(pending[.. String { + private func processCommand( + _ command: String, + connection: SocketConnection, + requestPolicy: SocketRequestPolicy + ) -> String { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "ERROR: Empty command" } @@ -1572,12 +1864,16 @@ class TerminalController { ) } - return processV2Command(trimmed, connection: connection) + return processV2Command(trimmed, connection: connection, requestPolicy: requestPolicy) } // MARK: - V2 JSON Socket Protocol - private func processV2Command(_ jsonLine: String, connection: SocketConnection) -> String { + private func processV2Command( + _ jsonLine: String, + connection: SocketConnection, + requestPolicy: SocketRequestPolicy + ) -> String { // v1 access-mode gating applies to v2 as well. We can't know which v2 method maps // to which v1 command without parsing, so parse first and then apply allow-list. @@ -1617,18 +1913,18 @@ class TerminalController { case "system.ping": return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": - return v2Ok(id: id, result: v2Capabilities()) + return v2Ok(id: id, result: v2Capabilities(requestPolicy: requestPolicy)) case "system.identify": - return v2Ok(id: id, result: v2Identify(params: params)) + return v2Ok(id: id, result: v2Identify(params: params, requestPolicy: requestPolicy)) case "system.tree": - return v2Result(id: id, self.v2SystemTree(params: params)) + return v2Result(id: id, self.v2SystemTree(params: params, requestPolicy: requestPolicy)) case "auth.login": return v2Ok( id: id, result: [ "authenticated": true, - "required": accessMode.requiresPasswordAuth + "required": requestPolicy.requiresPasswordAuthentication ] ) @@ -2139,12 +2435,12 @@ class TerminalController { } } - private func v2Capabilities() -> [String: Any] { + private func v2Capabilities(requestPolicy: SocketRequestPolicy) -> [String: Any] { return [ "protocol": "cmux-socket", "version": 2, - "socket_path": socketPath, - "access_mode": accessMode.rawValue, + "socket_path": requestPolicy.socketPath, + "access_mode": requestPolicy.accessMode.rawValue, "methods": V2CommandCatalog.methods.sorted() ] } @@ -2152,25 +2448,29 @@ class TerminalController { // MARK: - V2 Helpers (encoding + result plumbing) // MARK: - V2 Helpers (encoding + result plumbing) - func v2OrNull(_ value: Any?) -> Any { + nonisolated func v2OrNull(_ value: Any?) -> Any { // Avoid relying on `?? NSNull()` inference (Swift toolchains can disagree). if let value { return value } return NSNull() } - func v2MainSync(_ body: () -> T) -> T { + nonisolated func v2MainSync(_ body: @MainActor () -> T) -> T { if Thread.isMainThread { - return body() + return MainActor.assumeIsolated { + body() + } } // AppDelegate and Workspace focus guards run inside the main-thread closure, so carry // only this request's allowance across the hop and restore the main thread afterward. let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations() return DispatchQueue.main.sync { - Self.withSocketCommandFocusAllowance(allowsFocusMutation, body) + MainActor.assumeIsolated { + Self.withSocketCommandFocusAllowance(allowsFocusMutation, body) + } } } - private func v2Ok(id: Any?, result: Any) -> String { + private nonisolated func v2Ok(id: Any?, result: Any) -> String { return v2Encode([ "id": v2OrNull(id), "ok": true, @@ -2178,7 +2478,7 @@ class TerminalController { ]) } - private func v2Error(id: Any?, code: String, message: String, data: Any? = nil) -> String { + private nonisolated func v2Error(id: Any?, code: String, message: String, data: Any? = nil) -> String { var err: [String: Any] = ["code": code, "message": message] if let data { err["data"] = data @@ -2195,11 +2495,11 @@ class TerminalController { case err(code: String, message: String, data: Any?) } - func v2InvalidParam(_ what: String) -> V2CallResult { + nonisolated func v2InvalidParam(_ what: String) -> V2CallResult { return .err(code: "invalid_params", message: "Missing or invalid \(what)", data: nil) } - private func v2Result(id: Any?, _ res: V2CallResult) -> String { + private nonisolated func v2Result(id: Any?, _ res: V2CallResult) -> String { switch res { case .ok(let payload): return v2Ok(id: id, result: payload) @@ -2208,7 +2508,7 @@ class TerminalController { } } - private func v2Encode(_ object: Any) -> String { + private nonisolated func v2Encode(_ object: Any) -> String { guard JSONSerialization.isValidJSONObject(object), let data = try? JSONSerialization.data(withJSONObject: object, options: []), var s = String(data: data, encoding: .utf8) else { @@ -2220,26 +2520,11 @@ class TerminalController { return s } - private func v2EnsureHandleRef(kind: V2HandleKind, uuid: UUID) -> String { - v2HandleRefStateLock.lock() - defer { v2HandleRefStateLock.unlock() } - - if let existing = v2RefByUUID[kind]?[uuid] { - return existing - } - let next = v2NextHandleOrdinal[kind] ?? 1 - let ref = "\(kind.rawValue):\(next)" - var byUUID = v2RefByUUID[kind] ?? [:] - var byRef = v2UUIDByRef[kind] ?? [:] - byUUID[uuid] = ref - byRef[ref] = uuid - v2RefByUUID[kind] = byUUID - v2UUIDByRef[kind] = byRef - v2NextHandleOrdinal[kind] = next + 1 - return ref + private nonisolated func v2EnsureHandleRef(kind: V2HandleKind, uuid: UUID) -> String { + v2HandleRefStore.ensure(kind: kind, uuid: uuid) } - private func v2ResolveHandleRef(_ handle: String) -> UUID? { + private nonisolated func v2ResolveHandleRef(_ handle: String) -> UUID? { if let knownUUID = v2KnownUUID(forHandle: handle) { return knownUUID } @@ -2252,31 +2537,16 @@ class TerminalController { return v2KnownUUID(forHandle: handle) } - private func v2KnownUUID(forHandle handle: String) -> UUID? { - v2HandleRefStateLock.lock() - defer { v2HandleRefStateLock.unlock() } - - for kind in V2HandleKind.allCases { - if let id = v2UUIDByRef[kind]?[handle] { - return id - } - } - // Tab refs are aliases for surface refs in tab-facing APIs. - let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if trimmed.hasPrefix("tab:"), - let ordinal = Int(trimmed.replacingOccurrences(of: "tab:", with: "")), - let id = v2UUIDByRef[.surface]?["surface:\(ordinal)"] { - return id - } - return nil + private nonisolated func v2KnownUUID(forHandle handle: String) -> UUID? { + v2HandleRefStore.knownUUID(forHandle: handle) } - func v2Ref(kind: V2HandleKind, uuid: UUID?) -> Any { + nonisolated func v2Ref(kind: V2HandleKind, uuid: UUID?) -> Any { guard let uuid else { return NSNull() } return v2EnsureHandleRef(kind: kind, uuid: uuid) } - func v2TabRef(uuid: UUID?) -> Any { + nonisolated func v2TabRef(uuid: UUID?) -> Any { guard let uuid else { return NSNull() } let surfaceRef = v2EnsureHandleRef(kind: .surface, uuid: uuid) return surfaceRef.replacingOccurrences(of: "surface:", with: "tab:") @@ -2318,17 +2588,17 @@ class TerminalController { ) } - /// Drops `v2RefByUUID`/`v2UUIDByRef` entries for UUIDs no longer present in the live + /// Drops handle-ref entries for UUIDs no longer present in the live /// object graph (M8). Runs as a sweep at refresh time rather than adding teardown hooks /// at every window/workspace/pane/surface destruction site. /// - /// Never touches `v2NextHandleOrdinal`: the per-kind ordinal counter stays monotonic, so a + /// Never resets the store's per-kind ordinal counter, so it stays monotonic and a /// pruned-then-reappearing UUID gets a brand-new ref rather than reusing a number that /// might still be cached by a client as pointing at the old object. Only the map entries /// (the thing that actually grows unbounded) are dropped. /// Internal (not private) so the unit-test target can exercise the never-reissue invariant /// directly, bypassing the AppDelegate-dependent enumeration in `v2RefreshKnownRefs`. - func v2PruneDeadHandleRefs( + nonisolated func v2PruneDeadHandleRefs( liveWindowIds: Set, liveWorkspaceIds: Set, livePaneIds: Set, @@ -2341,30 +2611,18 @@ class TerminalController { .surface: liveSurfaceIds, ] - v2HandleRefStateLock.lock() - defer { v2HandleRefStateLock.unlock() } - - for kind in V2HandleKind.allCases { - guard let live = liveByKind[kind], var byUUID = v2RefByUUID[kind] else { continue } - var byRef = v2UUIDByRef[kind] ?? [:] - for (uuid, ref) in byUUID where !live.contains(uuid) { - byUUID.removeValue(forKey: uuid) - byRef.removeValue(forKey: ref) - } - v2RefByUUID[kind] = byUUID - v2UUIDByRef[kind] = byRef - } + v2HandleRefStore.prune(liveByKind: liveByKind) } // MARK: - V2 Param Parsing - func v2String(_ params: [String: Any], _ key: String) -> String? { + nonisolated func v2String(_ params: [String: Any], _ key: String) -> String? { guard let raw = params[key] as? String else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } - func v2StringArray(_ params: [String: Any], _ key: String) -> [String]? { + nonisolated func v2StringArray(_ params: [String: Any], _ key: String) -> [String]? { if let raw = params[key] as? [String] { let normalized = raw .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -2384,7 +2642,7 @@ class TerminalController { return nil } - func v2StringMap(_ params: [String: Any], _ key: String) -> [String: String]? { + nonisolated func v2StringMap(_ params: [String: Any], _ key: String) -> [String: String]? { guard let raw = params[key] else { return nil } if let dict = raw as? [String: String] { return dict @@ -2400,16 +2658,16 @@ class TerminalController { return nil } - func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { + nonisolated func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { guard let action = v2String(params, key) else { return nil } return action.lowercased().replacingOccurrences(of: "-", with: "_") } - func v2RawString(_ params: [String: Any], _ key: String) -> String? { + nonisolated func v2RawString(_ params: [String: Any], _ key: String) -> String? { params[key] as? String } - func v2UUID(_ params: [String: Any], _ key: String) -> UUID? { + nonisolated func v2UUID(_ params: [String: Any], _ key: String) -> UUID? { guard let s = v2String(params, key) else { return nil } if let uuid = UUID(uuidString: s) { return uuid @@ -2417,7 +2675,17 @@ class TerminalController { return v2ResolveHandleRef(s) } - func v2UUIDAny(_ raw: Any?) -> UUID? { + nonisolated func v2CachedUUID(_ params: [String: Any], _ key: String) -> UUID? { + guard let s = v2String(params, key) else { return nil } + if let uuid = UUID(uuidString: s) { + return uuid + } + // Telemetry must not block on main to refresh the handle store: clients can use UUIDs + // or already-issued cached refs, while unknown or unissued refs fail immediately by design. + return v2KnownUUID(forHandle: s) + } + + nonisolated func v2UUIDAny(_ raw: Any?) -> UUID? { guard let s = raw as? String else { return nil } let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -2426,7 +2694,7 @@ class TerminalController { } return v2ResolveHandleRef(trimmed) } - func v2Bool(_ params: [String: Any], _ key: String) -> Bool? { + nonisolated func v2Bool(_ params: [String: Any], _ key: String) -> Bool? { if let b = params[key] as? Bool { return b } if let n = params[key] as? NSNumber { return n.boolValue } if let s = params[key] as? String { @@ -2455,11 +2723,11 @@ class TerminalController { } return nil } - func v2Int(_ params: [String: Any], _ key: String) -> Int? { + nonisolated func v2Int(_ params: [String: Any], _ key: String) -> Int? { v2StrictIntAny(params[key]) } - func v2Double(_ params: [String: Any], _ key: String) -> Double? { + nonisolated func v2Double(_ params: [String: Any], _ key: String) -> Double? { if let d = params[key] as? Double { return d } if let n = params[key] as? NSNumber { return n.doubleValue } if let s = params[key] as? String { return Double(s) } @@ -2468,7 +2736,7 @@ class TerminalController { /// Parses an array-of-integers param (e.g. `ports`), also accepting a single scalar value. /// Returns `nil` if the param is present but contains a non-integer element. - func v2IntArray(_ params: [String: Any], _ key: String) -> [Int]? { + nonisolated func v2IntArray(_ params: [String: Any], _ key: String) -> [Int]? { guard let raw = params[key] as? [Any] else { if let single = v2Int(params, key) { return [single] } return nil @@ -2485,16 +2753,16 @@ class TerminalController { } - func v2HasNonNullParam(_ params: [String: Any], _ key: String) -> Bool { + nonisolated func v2HasNonNullParam(_ params: [String: Any], _ key: String) -> Bool { guard let raw = params[key] else { return false } return !(raw is NSNull) } - func v2StrictInt(_ params: [String: Any], _ key: String) -> Int? { + nonisolated func v2StrictInt(_ params: [String: Any], _ key: String) -> Int? { v2StrictIntAny(params[key]) } - private func v2StrictIntAny(_ raw: Any?) -> Int? { + private nonisolated func v2StrictIntAny(_ raw: Any?) -> Int? { guard let raw else { return nil } if let numberValue = raw as? NSNumber { @@ -2519,7 +2787,7 @@ class TerminalController { return nil } - func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? { + nonisolated func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? { guard let s = v2String(params, key) else { return nil } return PanelType(rawValue: s.lowercased()) } @@ -2527,19 +2795,38 @@ class TerminalController { // MARK: - V2 Context Resolution func v2ResolveTabManager(params: [String: Any]) -> TabManager? { - // Prefer explicit window_id routing. Fall back to global lookup by workspace_id/surface_id/tab_id, - // and finally to the active window's TabManager. - if let windowId = v2UUID(params, "window_id") { + // The highest-priority present selector is authoritative. Prefer registered managers. An id that + // no manager owns falls back to self.tabManager so the handler can report not_found for it + // (v2ResolveWorkspace fails there); returning nil would surface it as "unavailable" instead. + if v2HasNonNullParam(params, "window_id") { + guard let windowId = v2UUID(params, "window_id") else { return nil } return v2MainSync { AppDelegate.shared?.tabManagerFor(windowId: windowId) } } - if let wsId = v2UUID(params, "workspace_id") { - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(tabId: wsId) }) { - return tm + if v2HasNonNullParam(params, "workspace_id") { + guard let workspaceId = v2UUID(params, "workspace_id") else { return nil } + return v2MainSync { + if let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) { + return tabManager + } + return self.tabManager + } + } + if v2HasNonNullParam(params, "surface_id") { + guard let surfaceId = v2UUID(params, "surface_id") else { return nil } + return v2MainSync { + if let tabManager = AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager { + return tabManager + } + return self.tabManager } } - if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { - if let tm = v2MainSync({ AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager }) { - return tm + if v2HasNonNullParam(params, "tab_id") { + guard let tabId = v2UUID(params, "tab_id") else { return nil } + return v2MainSync { + if let tabManager = AppDelegate.shared?.locateSurface(surfaceId: tabId)?.tabManager { + return tabManager + } + return self.tabManager } } return v2MainSync { self.tabManager } @@ -2558,12 +2845,18 @@ class TerminalController { // AppDelegate+UITestCmdClick.swift, GhosttyTerminalView+Mouse.swift, and Workspace.swift. func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? { - if let wsId = v2UUID(params, "workspace_id") { - return tabManager.tabs.first(where: { $0.id == wsId }) + if v2HasNonNullParam(params, "workspace_id") { + guard let workspaceId = v2UUID(params, "workspace_id") else { return nil } + return tabManager.tabs.first(where: { $0.id == workspaceId }) } - if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { + if v2HasNonNullParam(params, "surface_id") { + guard let surfaceId = v2UUID(params, "surface_id") else { return nil } return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) } + if v2HasNonNullParam(params, "tab_id") { + guard let tabId = v2UUID(params, "tab_id") else { return nil } + return tabManager.tabs.first(where: { $0.panels[tabId] != nil }) + } guard let wsId = tabManager.selectedTabId else { return nil } return tabManager.tabs.first(where: { $0.id == wsId }) } @@ -2848,6 +3141,9 @@ class TerminalController { if let browserDownloadObserver { NotificationCenter.default.removeObserver(browserDownloadObserver) } + if let socketControlPasswordObserver { + NotificationCenter.default.removeObserver(socketControlPasswordObserver) + } stop() } } diff --git a/Sources/TerminalSurface.swift b/Sources/TerminalSurface.swift index 2ceeceb4..1fa3cef4 100644 --- a/Sources/TerminalSurface.swift +++ b/Sources/TerminalSurface.swift @@ -153,6 +153,13 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + /// Single-owner transport used only after registry removal and `surface` nil-ing. + /// The replay queue orders this free after earlier replay work; only the main-actor + /// task unwraps the pointer and frees it once. + private struct DeferredSurfaceFree: @unchecked Sendable { + let pointer: ghostty_surface_t + } + private(set) var surface: ghostty_surface_t? private weak var attachedView: GhosttyNSView? @@ -973,6 +980,8 @@ final class TerminalSurface: Identifiable, ObservableObject { } #endif + let deferredSurfaceFree = DeferredSurfaceFree(pointer: surfaceToFree) + #if DEBUG dlog( "surface.lifecycle.\(reason).begin surface=\(surfaceIdForTap.prefix(5)) " + @@ -989,6 +998,7 @@ final class TerminalSurface: Identifiable, ObservableObject { // needs is snapshotted above, same discipline as before. reviveReplayQueue.async { Task { @MainActor in + let surfaceToFree = deferredSurfaceFree.pointer // Keep free behavior aligned across teardown sites: perform the runtime // teardown on the next main-actor turn so SIGHUP delivery is // deterministic but non-reentrant. Clear the PTY tee right before diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 117a8cdc..c2ad4072 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -12,6 +12,18 @@ enum UpdateFeedResolver { } } +enum UpdateRelaunchPreparation { + static func performSynchronously(_ operation: @MainActor () -> Void) { + if Thread.isMainThread { + MainActor.assumeIsolated { operation() } + } else { + DispatchQueue.main.sync { + MainActor.assumeIsolated { operation() } + } + } + } +} + extension UpdateDriver: SPUUpdaterDelegate { func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool { false @@ -105,7 +117,9 @@ extension UpdateDriver: SPUUpdaterDelegate { } func updaterWillRelaunchApplication(_ updater: SPUUpdater) { - Task { @MainActor in + // Sparkle proceeds synchronously when this pre-relaunch callback returns, + // so preparation must finish here rather than be queued asynchronously. + UpdateRelaunchPreparation.performSynchronously { AppDelegate.shared?.persistSessionForUpdateRelaunch() TerminalController.shared.stop() NSApp.invalidateRestorableState() diff --git a/Sources/Update/UpdateTestSupport.swift b/Sources/Update/UpdateTestSupport.swift index e9cfeaad..144b9818 100644 --- a/Sources/Update/UpdateTestSupport.swift +++ b/Sources/Update/UpdateTestSupport.swift @@ -1,6 +1,7 @@ #if DEBUG import Foundation import Sparkle +import Sparkle_Private.SUAppcastItem enum UpdateTestSupport { static func applyIfNeeded(to viewModel: UpdateViewModel) { @@ -94,7 +95,19 @@ enum UpdateTestSupport { "pubDate": "Wed, 25 Mar 2026 12:00:00 +0000", "enclosure": enclosure, ] - return SUAppcastItem(dictionary: dict) + let comparator = SUStandardVersionComparator.default + let stateResolver = SPUAppcastItemStateResolver( + hostVersion: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0", + applicationVersionComparator: comparator, + standardVersionComparator: comparator + ) + return SUAppcastItem( + dictionary: dict, + relativeTo: nil, + stateResolver: stateResolver, + signingValidationStatus: .skipped, + failureReason: nil + ) } } #endif diff --git a/Sources/V2CommandCatalog.swift b/Sources/V2CommandCatalog.swift index e3009112..8722553b 100644 --- a/Sources/V2CommandCatalog.swift +++ b/Sources/V2CommandCatalog.swift @@ -80,6 +80,9 @@ enum V2CommandCatalog { "surface.ports_kick", "surface.read_text", "surface.wait", + "agent.prompt", + "subscribe", + "unsubscribe", "surface.clear_history", "surface.trigger_flash", "surface.report_pwd", @@ -238,10 +241,13 @@ enum V2CommandCatalog { "debug.terminal.render_stats", "debug.layout", "debug.portal.stats", + "debug.viewtree", "debug.bonsplit_underflow.count", "debug.bonsplit_underflow.reset", "debug.empty_panel.count", "debug.empty_panel.reset", + "debug.samples.stats", + "debug.samples.reset", "debug.notification.focus", "debug.flash.count", "debug.flash.reset", diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index 0f656b04..9a52ea16 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -296,6 +296,8 @@ struct VerticalTabsSidebar: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex, showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, + draggedTabIdSnapshot: draggedTabId, + dropIndicatorSnapshot: dropIndicator, draggedTabId: $draggedTabId, dropIndicator: $dropIndicator, contextMenuWorkspaceIds: contextMenuWorkspaceIds, diff --git a/Sources/WindowChrome.swift b/Sources/WindowChrome.swift index 108298dc..63e91290 100644 --- a/Sources/WindowChrome.swift +++ b/Sources/WindowChrome.swift @@ -90,6 +90,16 @@ enum WindowGlassEffect { static var contentCardCornerRadius: CGFloat { windowCornerRadius - contentCardInset } /// Radius for floating glass controls: tab pills, icon capsule clusters. static let controlCornerRadius: CGFloat = 10 + /// Shared "lit surface" tint for selected pills and control capsules. + /// A white lift reads as selection in dark mode, but the equivalent black + /// wash in light mode is muddy and drags the glass edge lensing into + /// visible gray rims at the capsule ends — light mode lifts with white too. + static func surfaceLiftTint(for appearance: NSAppearance, hover: Bool = false) -> NSColor { + if appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua { + return NSColor.white.withAlphaComponent(hover ? 0.06 : 0.12) + } + return NSColor.white.withAlphaComponent(hover ? 0.3 : 0.55) + } /// Gap between the content card and the window edges / sidebar. static let contentCardInset: CGFloat = 8 /// Inverted (Aside-style) backdrop. The window stays a completely standard diff --git a/Sources/WindowPaneChromePortal.swift b/Sources/WindowPaneChromePortal.swift index 25406b49..2a71d4bd 100644 --- a/Sources/WindowPaneChromePortal.swift +++ b/Sources/WindowPaneChromePortal.swift @@ -513,7 +513,11 @@ private final class PaneChromePortalHostView: NSView { @available(macOS 26.0, *) private final class NativePaneTabBarView: NSView { private let scrollView = NSScrollView(frame: .zero) - private let documentView = FlippedDocumentView(frame: .zero) + // Unflipped on purpose: a flipped document view mirrors the glass pills' + // built-in shadow upward (layer geometry flip flips shadowOffset), while + // the control capsules in the unflipped host cast theirs downward. Layout + // doesn't care — every child spans the full row height at y = 0. + private let documentView = NSView(frame: .zero) private var pillViews: [TabID: NativeGlassTabPillView] = [:] private var descriptor: BonsplitPaneChromeDescriptor? /// Safari-style "+" after the last pill; scrolls with the tabs. Bare glyph, @@ -637,10 +641,6 @@ private final class NativePaneTabBarView: NSView { } } -private final class FlippedDocumentView: NSView { - override var isFlipped: Bool { true } -} - /// A Maps-style always-visible control capsule: one glass surface holding a row /// of icon buttons (Maps groups map-mode + navigation in one pill the same way). @MainActor @@ -659,8 +659,6 @@ private final class GlassIconClusterView: NSView { #if compiler(>=6.2) glass.style = .regular glass.cornerRadius = WindowGlassEffect.controlCornerRadius - // Same surface tone as the selected pill (see applySurfaceState). - glass.tintColor = NSColor.labelColor.withAlphaComponent(0.12) glass.contentView = container #endif addSubview(glass) @@ -679,9 +677,22 @@ private final class GlassIconClusterView: NSView { } actions = Array(repeating: {}, count: symbols.count) defaultTooltips = symbols.map(\.tooltip) - // Full-presence like the selected pill; the shared tint above keeps the + // Full-presence like the selected pill; the shared tint keeps the // strip's three capsule surfaces in one material family. alphaValue = 1.0 + applySurfaceTint() + } + + /// Same surface tone as the selected pill (see NativeGlassTabPillView.applySurfaceState). + private func applySurfaceTint() { + #if compiler(>=6.2) + glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance) + #endif + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applySurfaceTint() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -794,11 +805,13 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { private func applySurfaceState() { #if compiler(>=6.2) // One shared surface tone across the strip: selected pills and the - // control capsules use labelColor@0.12; quiet pills stay untinted. + // control capsules share it; quiet pills stay untinted. labelColor@0.12 + // is a white lift in dark mode, but the same alpha in light mode is a + // black wash that reads muddy/pressed — halve it there. if isSelected { - glass.tintColor = NSColor.labelColor.withAlphaComponent(0.12) + glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance) } else if isHovered { - glass.tintColor = NSColor.labelColor.withAlphaComponent(0.06) + glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance, hover: true) } else { glass.tintColor = nil } @@ -806,6 +819,11 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource { alphaValue = isSelected ? 1.0 : (isHovered ? 0.94 : 0.85) } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applySurfaceState() + } + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { .move } @@ -895,13 +913,23 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS titleField.stringValue = tab.title titleField.font = .systemFont(ofSize: 13, weight: tab.isSelected ? .semibold : .medium) titleField.textColor = tab.isSelected ? .labelColor : .secondaryLabelColor - iconView.contentTintColor = tab.isSelected ? .labelColor : .secondaryLabelColor + // Selection reads through the title weight and surface tone; a primary- + // tinted filled glyph (terminal.fill is a solid box) overpowers the pill. + iconView.contentTintColor = .secondaryLabelColor closeButton.contentTintColor = .secondaryLabelColor if let data = tab.iconImageData, let image = NSImage(data: data) { iconView.image = image } else if let icon = tab.icon { - iconView.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil) + // Unconfigured symbols render at the full 16pt slot; boxy filled + // glyphs like terminal.fill then dominate the pill. Match the + // SwiftUI tab bar's smaller optical size. + let configuration = NSImage.SymbolConfiguration(pointSize: 12, weight: .medium) + // The filled variant is a solid box that dominates the pill; the + // strip's control capsules already use the outline variant. + let resolvedName = icon == "terminal.fill" ? "terminal" : icon + let symbol = NSImage(systemSymbolName: resolvedName, accessibilityDescription: nil) ?? NSImage(systemSymbolName: "terminal", accessibilityDescription: nil) + iconView.image = symbol?.withSymbolConfiguration(configuration) ?? symbol } else { iconView.image = nil } diff --git a/Sources/Workspace+Bonsplit.swift b/Sources/Workspace+Bonsplit.swift index d75fafdc..0358ad85 100644 --- a/Sources/Workspace+Bonsplit.swift +++ b/Sources/Workspace+Bonsplit.swift @@ -610,7 +610,11 @@ extension Workspace: @preconcurrency BonsplitDelegate { ) if isUndoStaging, let originalIndex = undoStageOriginalIndex, let staged = pendingDetachedSurfaces.removeValue(forKey: tabId) { - onTerminalCloseStagedForUndo?(staged, pane, originalIndex) + if let onTerminalCloseStagedForUndo { + onTerminalCloseStagedForUndo(staged, pane, originalIndex) + } else { + staged.finalizePermanently() + } } } else { if let closedBrowserRestoreSnapshot { @@ -781,13 +785,7 @@ extension Workspace: @preconcurrency BonsplitDelegate { // automation state must survive the trip, so it is only pruned on // permanent close. if !isDetaching { - TerminalController.shared.v2BrowserInitScriptsBySurface.removeValue(forKey: panelId) - TerminalController.shared.v2BrowserInitStylesBySurface.removeValue(forKey: panelId) - TerminalController.shared.v2BrowserDownloadEventsBySurface.removeValue(forKey: panelId) - TerminalController.shared.v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: panelId) - TerminalController.shared.v2BrowserFrameSelectorBySurface.removeValue(forKey: panelId) - TerminalController.shared.v2BrowserElementRefs = TerminalController.shared.v2BrowserElementRefs - .filter { $0.value.surfaceId != panelId } + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panelId) } if progressSourcePanelId == panelId { progress = nil diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bd16af94..f4e761f8 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -613,7 +613,27 @@ final class Workspace: Identifiable, ObservableObject { let splitPanelId: UUID } - struct DetachedSurfaceTransfer { + struct DetachedSurfaceAttachmentTarget { + let workspace: Workspace + let paneId: PaneID + let index: Int? + let focus: Bool + } + + enum DetachedSurfaceResolutionResult { + case attachedPrimary(UUID) + case attachedRollback(UUID) + case finalized + } + + @MainActor + final class DetachedSurfaceTransfer { + private enum State { + case pending + case attached(UUID) + case finalized + } + let panelId: UUID let panel: any Panel let title: String @@ -629,27 +649,106 @@ final class Workspace: Identifiable, ObservableObject { let manuallyUnread: Bool let isRemoteTerminal: Bool let remoteRelayPort: Int? - let remoteCleanupConfiguration: WorkspaceRemoteConfiguration? - + private(set) var remoteConfigurationIdentity: WorkspaceRemoteConfiguration? + private(set) var remoteCleanupConfiguration: WorkspaceRemoteConfiguration? + private var state: State = .pending + + init( + panelId: UUID, + panel: any Panel, + title: String, + icon: String?, + iconImageData: Data?, + kind: String?, + isLoading: Bool, + isPinned: Bool, + directory: String?, + ttyName: String?, + cachedTitle: String?, + customTitle: String?, + manuallyUnread: Bool, + isRemoteTerminal: Bool, + remoteRelayPort: Int?, + remoteCleanupConfiguration: WorkspaceRemoteConfiguration? + ) { + self.panelId = panelId + self.panel = panel + self.title = title + self.icon = icon + self.iconImageData = iconImageData + self.kind = kind + self.isLoading = isLoading + self.isPinned = isPinned + self.directory = directory + self.ttyName = ttyName + 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 { - Self( - panelId: panelId, - panel: panel, - title: title, - icon: icon, - iconImageData: iconImageData, - kind: kind, - isLoading: isLoading, - isPinned: isPinned, - directory: directory, - ttyName: ttyName, - cachedTitle: cachedTitle, - customTitle: customTitle, - manuallyUnread: manuallyUnread, - isRemoteTerminal: isRemoteTerminal, - remoteRelayPort: remoteRelayPort, - remoteCleanupConfiguration: configuration - ) + guard case .pending = state else { return self } + remoteCleanupConfiguration = configuration + return self + } + + fileprivate var isPending: Bool { + if case .pending = state { return true } + return false + } + + fileprivate func markAttached(to workspaceId: UUID) -> Bool { + guard case .pending = state else { return false } + state = .attached(workspaceId) + return true + } + + func resolve( + primary: DetachedSurfaceAttachmentTarget, + rollback: DetachedSurfaceAttachmentTarget? + ) -> DetachedSurfaceResolutionResult { + guard isPending else { return .finalized } + if let panelId = primary.workspace.attachDetachedSurface( + self, + inPane: primary.paneId, + atIndex: primary.index, + focus: primary.focus + ) { + return .attachedPrimary(panelId) + } + if let rollback, + let panelId = rollback.workspace.attachDetachedSurface( + self, + inPane: rollback.paneId, + atIndex: rollback.index, + focus: rollback.focus + ) { + return .attachedRollback(panelId) + } + finalizePermanently() + return .finalized + } + + func finalizePermanently() { + guard case .pending = state else { return } + state = .finalized + if let remoteCleanupConfiguration { + Workspace.requestSSHControlMasterCleanupIfNeeded(configuration: remoteCleanupConfiguration) + } + panel.close() + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panelId) } } @@ -1197,8 +1296,17 @@ 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) @@ -1629,7 +1737,7 @@ final class Workspace: Identifiable, ObservableObject { defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) - pendingDetachedSurfaces.removeValue(forKey: tabId) + pendingDetachedSurfaces.removeValue(forKey: tabId)?.finalizePermanently() forceCloseTabIds.remove(tabId) #if DEBUG dlog( @@ -1641,10 +1749,13 @@ final class Workspace: Identifiable, ObservableObject { } var detached = pendingDetachedSurfaces.removeValue(forKey: tabId) - if shouldSkipControlMasterCleanupAfterDetach, let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { - skipControlMasterCleanupAfterDetachedRemoteTransfer = true - if detachedTransfer.remoteCleanupConfiguration == nil { - detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + if let detachedTransfer = detached, detachedTransfer.isRemoteTerminal { + detached = detachedTransfer.withRemoteConfigurationIdentity(remoteConfiguration) + if shouldSkipControlMasterCleanupAfterDetach { + skipControlMasterCleanupAfterDetachedRemoteTransfer = true + if detachedTransfer.remoteCleanupConfiguration == nil { + detached = detachedTransfer.withRemoteCleanupConfiguration(remoteConfiguration) + } } } #if DEBUG @@ -1671,6 +1782,7 @@ final class Workspace: Identifiable, ObservableObject { "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" ) #endif + guard detached.isPending else { return nil } guard bonsplitController.allPaneIds.contains(paneId) else { #if DEBUG dlog( @@ -1764,8 +1876,8 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTabId] = detached.panelId let didAdoptWorkspaceRemoteTracking = - detached.isRemoteTerminal - && detached.remoteRelayPort == remoteConfiguration?.relayPort + detached.remoteConfigurationIdentity != nil + && detached.remoteConfigurationIdentity == remoteConfiguration if didAdoptWorkspaceRemoteTracking { trackRemoteTerminalSurface(detached.panelId) } @@ -1794,6 +1906,7 @@ final class Workspace: Identifiable, ObservableObject { scheduleFocusReconcile() } scheduleTerminalGeometryReconcile() + guard detached.markAttached(to: id) else { return nil } #if DEBUG dlog( diff --git a/Sources/WorkspaceSidebarModels.swift b/Sources/WorkspaceSidebarModels.swift index 7d40416c..fd7d38cf 100644 --- a/Sources/WorkspaceSidebarModels.swift +++ b/Sources/WorkspaceSidebarModels.swift @@ -59,7 +59,7 @@ struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } -enum SidebarLogLevel: String { +enum SidebarLogLevel: String, Sendable { case info case progress case success @@ -92,13 +92,13 @@ struct SidebarPanelObservationState: Equatable { } } -enum SidebarPullRequestStatus: String { +enum SidebarPullRequestStatus: String, Sendable { case open case merged case closed } -enum SidebarPullRequestChecksStatus: String { +enum SidebarPullRequestChecksStatus: String, Sendable { case pass case fail case pending diff --git a/docs/agent-browser-port-spec.md b/docs/agent-browser-port-spec.md index 393648d3..956c5215 100644 --- a/docs/agent-browser-port-spec.md +++ b/docs/agent-browser-port-spec.md @@ -248,9 +248,14 @@ P2 (advanced parity / optional): ### Object/Handle Semantics 1. stable handles: `window_id`, `workspace_id`, `pane_id`, `surface_id` -2. browser refs (`@e1`) are session-local and ephemeral -3. move/reorder must preserve `surface_id` -4. responses may include `index` for debugging/order, but requests should accept IDs +2. browser refs (`@e1`) are session-local and ephemeral; the same selector reuses its ref within the current navigation only +3. refs from the immediately previous navigation report `stale_element`; refs older than that report `not_found` +4. each surface may retain 4,096 unique refs in its current navigation; further unique allocations return `resource_exhausted` until the caller navigates or reuses a selector +5. each selector is limited to 16 KiB of UTF-8 and current-generation selectors are limited to 4 MiB per surface; byte-capacity failures use the same `resource_exhausted` result +6. snapshots visit at most 4,096 DOM nodes to a maximum depth of 64 and return at most 256 unique element entries; selectors, names, and roles are limited to 16,384, 1,024, and 64 UTF-8 bytes, with a 262,144-byte aggregate entry budget +7. snapshot titles and URLs are limited to 1,024 and 16,384 UTF-8 bytes; page text and HTML are limited to 262,144 and 1,048,576 characters. `truncation_reasons` identifies active node, entry, aggregate, or field limits in deterministic order, while `text_truncated` and `html_truncated` report page-payload truncation independently +8. move/reorder must preserve `surface_id`; a detached surface retains its browser refs while a destination or source rollback owns it, and failed attachment finalizes the panel and its automation state exactly once +9. responses may include `index` for debugging/order, but requests should accept IDs ## CLI Spec (Proposed) diff --git a/docs/v2-api-migration.md b/docs/v2-api-migration.md index faddd7b4..65ea2184 100644 --- a/docs/v2-api-migration.md +++ b/docs/v2-api-migration.md @@ -272,7 +272,7 @@ Request params: | `workspace_id` / `surface_id` | string (id or ref) | no | Same resolution as other `surface.*` methods; defaults to the current workspace's focused surface. | | `text` | string | yes | The prompt. Enter is always submitted after it (trailing whitespace/newlines in `text` are trimmed first, so this never sends a stray blank line) — callers don't pass their own line ending. | | `timeout_ms` | int | no | Overall budget for the agent to finish. Default `120000`. Also accepts `timeout`. | -| `working_grace_ms` | int | no | How long to wait for the agent to report it started working before giving up on observing that transition. Default `3000`. | +| `working_grace_ms` | int | no | How long to wait for the agent to report it started working before giving up on observing that transition, capped by the remaining overall `timeout_ms` budget. Default `3000`. | Response (`ok: true`): @@ -302,12 +302,13 @@ hooks were never installed for this surface. registered via `AgentStateWaitRegistry`. This closes the race where a hook reacts to the injected text before a separately-registered watcher would exist (the same atomic check+register pattern `surface.wait` uses). -2. **Grace window (`working_grace_ms`).** Wait for the `working` transition. +2. **Grace window (`working_grace_ms`).** Wait for the `working` transition, capped by the + remaining overall `timeout_ms` budget. - Observed → go to step 3. - - Not observed by the time the grace window elapses → there is nothing further useful to - wait for, so resolve immediately using whatever `agent_state` the surface already has - (`working_observed: false`). This is deliberately not a hard error: a prompt can finish - faster than the grace window, or a hook simply may not fire for a trivial prompt. + - Not observed by the time the grace window or overall deadline elapses → there is nothing + further useful to wait for, so resolve immediately using whatever `agent_state` the surface + already has (`working_observed: false`). This is deliberately not a timeout error: a prompt + can finish faster than the grace window, or a hook simply may not fire for a trivial prompt. 3. **Wait for idle.** Having observed `working`, wait — for the *remaining* overall `timeout_ms` budget — for `agent_state` to reach `idle` (the same no-state-is-idle rule as `surface.wait` applies: a hook that clears its own state on session end also counts). diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 72528787..fd4ba064 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -1,4 +1,5 @@ import XCTest +import Darwin import Combine #if canImport(Programa_DEV) @@ -12,6 +13,13 @@ private final class FakeWKInspectorContainerView: NSView {} private final class FocusableTestView: NSView { override var acceptsFirstResponder: Bool { true } } +private final class CloseConfirmationButtonRecorder: NSObject { + private(set) var clickCount = 0 + + @objc func recordClick(_ sender: NSButton) { + clickCount += 1 + } +} @MainActor final class AppDelegateShortcutRoutingTests: XCTestCase { @@ -102,6 +110,328 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { super.tearDown() } + func testDuplicateInstanceArbitrationLetsLaterStartSecondWinAndEarlierStartLose() { + let earlier = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 999_999, + processIdentifier: 200 + ) + let later = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 100 + ) + + XCTAssertTrue(AppDelegate.shouldTerminateDuplicateInstance(current: later, other: earlier)) + XCTAssertFalse(AppDelegate.shouldTerminateDuplicateInstance(current: earlier, other: later)) + } + + func testDuplicateInstanceArbitrationLetsLaterStartMicrosecondWinAndEarlierStartLose() { + let earlier = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 200 + ) + let later = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 101, + processIdentifier: 100 + ) + + XCTAssertTrue(AppDelegate.shouldTerminateDuplicateInstance(current: later, other: earlier)) + XCTAssertFalse(AppDelegate.shouldTerminateDuplicateInstance(current: earlier, other: later)) + } + + func testDuplicateInstanceArbitrationUsesPIDToElectOneWinnerForIdenticalTimestamps() { + let lowerPID = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let higherPID = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 200 + ) + let higherPIDWins = AppDelegate.shouldTerminateDuplicateInstance(current: higherPID, other: lowerPID) + let lowerPIDWins = AppDelegate.shouldTerminateDuplicateInstance(current: lowerPID, other: higherPID) + + XCTAssertTrue(higherPIDWins) + XCTAssertFalse(lowerPIDWins) + XCTAssertNotEqual(higherPIDWins, lowerPIDWins, "Identical kernel timestamps must elect exactly one winner") + } + + func testDuplicateInstanceCandidateExcludesEmbeddedCLIExecutable() { + let embeddedCLIURL = URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 200, + candidateExecutableURL: embeddedCLIURL, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: embeddedCLIURL + )) + } + + func testDuplicateInstanceCandidateIncludesSameBundleGUIExecutable() { + XCTAssertTrue(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 200, + candidateExecutableURL: URL( + fileURLWithPath: "/Applications/Programa.app/Contents/MacOS/Programa" + ), + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + )) + } + + func testDuplicateInstanceCandidateRejectsMissingExecutableMetadata() { + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 200, + candidateExecutableURL: nil, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + )) + } + + func testDuplicateInstanceTerminationWaitsForGraceBeforeForcing() throws { + var gracefulTerminationCount = 0 + var forcedTerminationCount = 0 + var scheduledGraceAction: (@MainActor () -> Void)? + + AppDelegate.scheduleDuplicateTerminationForTesting( + requestTermination: { + gracefulTerminationCount += 1 + return true + }, + scheduleGrace: { action in + scheduledGraceAction = action + }, + forceTerminationIfStillMatching: { + forcedTerminationCount += 1 + return true + } + ) + + XCTAssertEqual(gracefulTerminationCount, 1) + XCTAssertEqual(forcedTerminationCount, 0, "Force termination must not run synchronously") + + let graceAction = try XCTUnwrap(scheduledGraceAction) + graceAction() + + XCTAssertEqual(forcedTerminationCount, 1) + } + + func testValidatedDuplicateShutdownRequestTargetsExactCurrentProcessAndBypassesWarning() { + let current = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requester = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ) + let request = AppDelegate.SingleInstanceShutdownRequest( + target: current, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + + let accepted = AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + request, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + ) + + XCTAssertTrue(accepted) + XCTAssertFalse(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + hasValidatedDuplicateShutdownRequest: accepted, + isQuitWarningEnabled: true + )) + } + + func testDuplicateShutdownRequestFailsClosedWhenMissingStaleMalformedOrMismatched() { + let current = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let requester = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 200 + ) + let wrongTarget = ProgramaSingleInstanceProcessKey( + startSeconds: 999, + startMicroseconds: 999, + processIdentifier: 99 + ) + let staleRequest = AppDelegate.SingleInstanceShutdownRequest( + target: current, + requester: requester, + createdAtUnixSeconds: 9_000 + ) + let malformedVersionRequest = AppDelegate.SingleInstanceShutdownRequest( + version: AppDelegate.SingleInstanceShutdownRequest.currentVersion + 1, + target: current, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + let mismatchedTargetRequest = AppDelegate.SingleInstanceShutdownRequest( + target: wrongTarget, + requester: requester, + createdAtUnixSeconds: 10_000 + ) + + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + nil, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + staleRequest, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + malformedVersionRequest, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + mismatchedTargetRequest, + currentProcessKey: current, + now: 10_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + staleRequest, + currentProcessKey: current, + now: 9_001, + resolvedRequesterKey: wrongTarget, + requesterIsProgramaGUI: true + )) + XCTAssertFalse(AppDelegate.shouldAcceptDuplicateShutdownRequestForTesting( + staleRequest, + currentProcessKey: current, + now: 9_001, + resolvedRequesterKey: requester, + requesterIsProgramaGUI: false + )) + } + + func testOrdinaryQuitStillWarnsWhenWarningIsEnabled() { + XCTAssertTrue(AppDelegate.shouldWarnBeforeTerminationForTesting( + isTaggedDevBuild: false, + isQuitWarningConfirmed: false, + hasValidatedDuplicateShutdownRequest: false, + isQuitWarningEnabled: true + )) + } + + func testDuplicateForceFallbackRequiresSameLiveProcessIdentity() { + let expected = ProgramaSingleInstanceProcessKey( + startSeconds: 1_000, + startMicroseconds: 100, + processIdentifier: 100 + ) + let changed = ProgramaSingleInstanceProcessKey( + startSeconds: 1_001, + startMicroseconds: 0, + processIdentifier: 100 + ) + + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: nil, + isTerminated: false, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: changed, + isTerminated: false, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: true, + requestIsPending: true + )) + XCTAssertFalse(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: false, + requestIsPending: false + )) + XCTAssertTrue(AppDelegate.shouldForceDuplicateTerminationForTesting( + expectedProcessKey: expected, + resolvedProcessKey: expected, + isTerminated: false, + requestIsPending: true + )) + } + + func testDuplicateInstanceCandidateRejectsCurrentProcessAndDifferentBundle() { + let embeddedCLIURL = URL( + fileURLWithPath: "/Applications/Programa.app/Contents/Resources/bin/programa" + ) + let guiURL = URL(fileURLWithPath: "/Applications/Programa.app/Contents/MacOS/Programa") + + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.darkroom.programa", + candidateProcessIdentifier: 100, + candidateExecutableURL: guiURL, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: embeddedCLIURL + )) + XCTAssertFalse(AppDelegate.shouldConsiderDuplicateApplication( + candidateBundleIdentifier: "com.example.other", + candidateProcessIdentifier: 200, + candidateExecutableURL: guiURL, + expectedBundleIdentifier: "com.darkroom.programa", + currentProcessIdentifier: 100, + embeddedCLIURL: embeddedCLIURL + )) + } + + func testSingleInstanceProcessKeyReadsCurrentKernelProcessIdentity() throws { + let currentPID = getpid() + let key = try XCTUnwrap(AppDelegate.singleInstanceProcessKey(for: currentPID)) + + XCTAssertEqual(key.processIdentifier, currentPID) + XCTAssertGreaterThan(key.startSeconds, 0, "The current process must have a positive kernel start timestamp") + } + + func testSingleInstanceProcessKeyRejectsMissingKernelProcessRecord() { + XCTAssertNil(AppDelegate.singleInstanceProcessKey(for: pid_t.max)) + } + func testOrphanReconciliationRetainsOneRecoveryWorkspacePerSuccessfulSessionOnly() throws { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -1363,6 +1693,156 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active") } + func testCmdDClicksOnlyCloseConfirmationInEventPanel() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let confirmationTitle = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + let closeTitle = String(localized: "common.close", defaultValue: "Close") + let firstRecorder = CloseConfirmationButtonRecorder() + let secondRecorder = CloseConfirmationButtonRecorder() + + func makePanel(recorder: CloseConfirmationButtonRecorder) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 140), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: panel.contentView?.bounds ?? .zero) + let label = NSTextField(labelWithString: confirmationTitle) + let button = NSButton( + title: closeTitle, + target: recorder, + action: #selector(CloseConfirmationButtonRecorder.recordClick(_:)) + ) + content.addSubview(label) + content.addSubview(button) + panel.contentView = content + panel.orderFrontRegardless() + return panel + } + + let firstPanel = makePanel(recorder: firstRecorder) + let secondPanel = makePanel(recorder: secondRecorder) + defer { + firstPanel.orderOut(nil) + secondPanel.orderOut(nil) + firstPanel.close() + secondPanel.close() + } + + let panelsInGlobalOrder = NSApp.windows.compactMap { window -> NSPanel? in + guard let panel = window as? NSPanel, + panel === firstPanel || panel === secondPanel else { return nil } + return panel + } + guard panelsInGlobalOrder.count == 2 else { + XCTFail("Expected both close-confirmation panels in NSApp.windows") + return + } + let globallyFirstPanel = panelsInGlobalOrder[0] + let eventPanel = panelsInGlobalOrder[1] + let globallyFirstRecorder = globallyFirstPanel === firstPanel ? firstRecorder : secondRecorder + let eventRecorder = eventPanel === firstPanel ? firstRecorder : secondRecorder + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: eventPanel.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event for the second close-confirmation panel") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(eventRecorder.clickCount, 1, "Cmd+D must confirm the alert in the shortcut event's window context") + XCTAssertEqual(globallyFirstRecorder.clickCount, 0, "Cmd+D must not confirm a same-titled alert owned by another window") + } + + func testCloseConfirmationSheetInAnotherWindowDoesNotInterceptCmdD() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let firstWindow = window(withId: firstWindowId), + let secondWindow = window(withId: secondWindowId), + let secondWorkspace = appDelegate.tabManagerFor(windowId: secondWindowId)?.selectedWorkspace else { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + XCTFail("Expected both window contexts") + return + } + + let recorder = CloseConfirmationButtonRecorder() + let sheet = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 140), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: sheet.contentView?.bounds ?? .zero) + content.addSubview(NSTextField( + labelWithString: String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + )) + content.addSubview(NSButton( + title: String(localized: "common.close", defaultValue: "Close"), + target: recorder, + action: #selector(CloseConfirmationButtonRecorder.recordClick(_:)) + )) + sheet.contentView = content + firstWindow.beginSheet(sheet) + defer { + if firstWindow.attachedSheet === sheet { + firstWindow.endSheet(sheet) + } + sheet.orderOut(nil) + sheet.close() + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + waitUntil(description: "close-confirmation sheet to attach to the first window") { + firstWindow.attachedSheet === sheet && sheet.isVisible + } + + secondWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + appDelegate.tabManager = firstManager + let secondSurfaceCount = secondWorkspace.panels.count + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event for the window without a confirmation") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(recorder.clickCount, 0, "A confirmation attached to another window must not consume Cmd+D") + waitUntil(description: "Cmd+D to reach the event window while another window has a confirmation") { + secondWorkspace.panels.count == secondSurfaceCount + 1 + } + XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1) + } + func testConfiguredOpenReviewShortcutOpensReviewPanel() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -1587,6 +2067,62 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "Confirmed close should unregister the window's context") } + func testClosingMainWindowTearsDownEveryOwnedWorkspaceAndBrowserElementRef() throws { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + var surfaceIds: [UUID] = [] + defer { + if appDelegate.tabManagerFor(windowId: windowId) != nil { + closeWindow(withId: windowId) + } + for surfaceId in surfaceIds { + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) + } + } + + let manager = try XCTUnwrap(appDelegate.tabManagerFor(windowId: windowId)) + let firstWorkspace = try XCTUnwrap(manager.tabs.first) + let secondWorkspace = manager.addTab(select: false) + let workspaces = [firstWorkspace, secondWorkspace] + XCTAssertEqual(manager.tabs.count, 2) + + var refs: [(surfaceId: UUID, ref: String)] = [] + for (index, workspace) in workspaces.enumerated() { + let surfaceId = try XCTUnwrap(workspace.panels.keys.first) + surfaceIds.append(surfaceId) + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: ["#window-owned-workspace-\(index)"] + ) { + case .allocated(let allocated): + let ref = try XCTUnwrap(allocated.first) + refs.append((surfaceId, ref)) + XCTAssertNotNil(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: surfaceId)) + case .resourceExhausted: + XCTFail("A fresh workspace surface must accept its first browser element ref") + } + } + + closeWindow(withId: windowId) + + XCTAssertNil(appDelegate.tabManagerFor(windowId: windowId), "The real window close path must unregister its context") + for workspace in workspaces { + XCTAssertTrue(workspace.panels.isEmpty, "Closing a window must tear down every workspace it still owns") + } + for (surfaceId, ref) in refs { + switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: surfaceId) { + case .err(let code, _, _): + XCTAssertEqual(code, "not_found", "Whole-window teardown must permanently remove every owned surface ref") + case .ok: + XCTFail("A ref from a closed window must not remain resolvable") + } + } + } + func testTabManagerWindowCloseTeardownStopsEveryLifecycleResourceIdempotently() throws { let manager = TabManager() defer { manager.teardownForWindowClose() } @@ -3898,6 +4434,93 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { wait(for: [dismissExpectation], timeout: 0.2) } + func testTerminalMarkedTextBypassesRecentPalettePendingOpenEscapeGrace() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + guard let window = window(withId: windowId), + let workspace = appDelegate.tabManagerFor(windowId: windowId)?.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId), + let terminalView = surfaceView(in: terminalPanel.hostedView) else { + closeWindow(withId: windowId) + XCTFail("Expected focused terminal surface") + return + } + defer { + terminalView.markedText = NSMutableAttributedString() + appDelegate.setCommandPaletteVisible(true, for: window) + appDelegate.setCommandPaletteVisible(false, for: window) + closeWindow(withId: windowId) + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + terminalPanel.hostedView.suppressReparentFocus() + XCTAssertTrue(window.makeFirstResponder(terminalView)) + terminalPanel.hostedView.clearSuppressReparentFocus() + terminalView.markedText = NSMutableAttributedString(string: "composing") + XCTAssertTrue(terminalView.hasMarkedText()) + XCTAssertTrue(window.firstResponder === terminalView) + +#if DEBUG + XCTAssertTrue( + appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 0.1), + "Expected deterministic recent pending-open state" + ) +#else + XCTFail("debugSetCommandPalettePendingOpenAge is only available in DEBUG") +#endif + appDelegate.setCommandPaletteVisible(false, for: window) + + var toggleCount = 0 + var dismissCount = 0 + let toggleToken = NotificationCenter.default.addObserver( + forName: .commandPaletteToggleRequested, + object: nil, + queue: nil + ) { notification in + guard notification.object as? NSWindow === window else { return } + toggleCount += 1 + } + let dismissToken = NotificationCenter.default.addObserver( + forName: .commandPaletteDismissRequested, + object: nil, + queue: nil + ) { notification in + guard notification.object as? NSWindow === window else { return } + dismissCount += 1 + } + defer { + NotificationCenter.default.removeObserver(toggleToken) + NotificationCenter.default.removeObserver(dismissToken) + } + + guard let escapeEvent = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Escape event") + return + } + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: escapeEvent), + "Terminal IME composition must take precedence over pending-open Escape grace" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + XCTAssertEqual(toggleCount, 0) + XCTAssertEqual(dismissCount, 0) + } + func testEscapeDismissesMenuTriggeredCommandPaletteWhenVisibilitySyncIsStale() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -4026,6 +4649,75 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { #endif } + func testTerminalMarkedTextBypassesPostDismissEscapeSuppression() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + guard let window = window(withId: windowId), + let workspace = appDelegate.tabManagerFor(windowId: windowId)?.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId), + let terminalView = surfaceView(in: terminalPanel.hostedView) else { + closeWindow(withId: windowId) + XCTFail("Expected focused terminal surface") + return + } + defer { + terminalView.markedText = NSMutableAttributedString() + appDelegate.setCommandPaletteVisible(false, for: window) + closeWindow(withId: windowId) + } + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + appDelegate.setCommandPaletteVisible(true, for: window) + + guard let firstEscape = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber + ), let repeatedEscape = makeKeyDownEvent( + key: "\u{1b}", + modifiers: [], + keyCode: 53, + windowNumber: window.windowNumber, + isARepeat: true + ) else { + XCTFail("Failed to construct Escape events") + return + } + +#if DEBUG + XCTAssertTrue( + appDelegate.debugHandleCustomShortcut(event: firstEscape), + "The initial Escape must dismiss the visible palette and seed suppression" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + appDelegate.setCommandPaletteVisible(false, for: window) + + terminalPanel.hostedView.suppressReparentFocus() + XCTAssertTrue(window.makeFirstResponder(terminalView)) + terminalPanel.hostedView.clearSuppressReparentFocus() + terminalView.markedText = NSMutableAttributedString(string: "composing") + XCTAssertTrue(terminalView.hasMarkedText()) + XCTAssertTrue(window.firstResponder === terminalView) + +#if DEBUG + XCTAssertFalse( + appDelegate.debugHandleCustomShortcut(event: repeatedEscape), + "Terminal IME composition must take precedence over post-dismiss Escape suppression" + ) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + } + func testEscapeKeyUpIsConsumedAfterPaletteDismissToPreventTerminalLeak() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/programaTests/BrowserPanelTests.swift b/programaTests/BrowserPanelTests.swift index 23f31a17..d697f440 100644 --- a/programaTests/BrowserPanelTests.swift +++ b/programaTests/BrowserPanelTests.swift @@ -391,6 +391,983 @@ final class BrowserPanelReactGrabBridgeTests: XCTestCase { } +@MainActor +final class BrowserSnapshotJavaScriptPolicyTests: XCTestCase { + private func snapshot( + _ panel: BrowserPanel, + interactiveOnly: Bool = false, + includeCursor: Bool = false, + compact: Bool = false, + maxDepth: Int = 64, + scopeSelector: String? = nil + ) async throws -> [String: Any] { + let script = TerminalController.shared.v2BrowserSnapshotJavaScript( + interactiveOnly: interactiveOnly, + includeCursor: includeCursor, + compact: compact, + maxDepth: maxDepth, + scopeSelector: scopeSelector + ) + let value = try await panel.evaluateJavaScript(script) + return try XCTUnwrap(value as? [String: Any]) + } + + /// Runs the same isolated-world collector seam used by `browser.snapshot` after the page + /// has had a chance to replace page-world globals. + private func productionSnapshot( + _ panel: BrowserPanel, + interactiveOnly: Bool = false, + includeCursor: Bool = false, + compact: Bool = false, + maxDepth: Int = 64, + scopeSelector: String? = nil + ) throws -> [String: Any] { + let script = TerminalController.shared.v2BrowserSnapshotJavaScript( + interactiveOnly: interactiveOnly, + includeCursor: includeCursor, + compact: compact, + maxDepth: maxDepth, + scopeSelector: scopeSelector + ) + return try XCTUnwrap( + TerminalController.shared.v2BrowserCollectSnapshotJavaScriptResult( + webView: panel.webView, + surfaceId: panel.id, + script: script + ) + ) + } + + private func assertGeneratedHTMLIsBounded( + _ panel: BrowserPanel, + setupScript: String, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + _ = try await panel.evaluateJavaScript(setupScript) + let fullHTMLValue = try await panel.evaluateJavaScript("String(document.documentElement.outerHTML)") as? String + let fullHTML = try XCTUnwrap(fullHTMLValue, file: file, line: line) + XCTAssertGreaterThan(fullHTML.count, TerminalController.v2BrowserSnapshotHTMLCharacterLimit, file: file, line: line) + + let result = try await snapshot(panel) + let html = try XCTUnwrap(result["html"] as? String, file: file, line: line) + XCTAssertEqual(html.count, TerminalController.v2BrowserSnapshotHTMLCharacterLimit, file: file, line: line) + XCTAssertEqual( + html, + String(fullHTML.prefix(TerminalController.v2BrowserSnapshotHTMLCharacterLimit)), + "The bounded serializer must preserve the exact deterministic document prefix", + file: file, + line: line + ) + XCTAssertEqual(result["html_truncated"] as? Bool, true, file: file, line: line) + } + + private func entries(in result: [String: Any]) throws -> [[String: Any]] { + try XCTUnwrap(result["entries"] as? [[String: Any]]) + } + + private func reasons(in result: [String: Any]) -> [String] { + result["truncation_reasons"] as? [String] ?? [] + } + + private func javaScriptStringLiteral(_ value: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [value]) + return String(try XCTUnwrap(String(data: data, encoding: .utf8)).dropFirst().dropLast()) + } + + func testSnapshotStopsAfterTheBoundedNodePrefixBeforeAButton() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.replaceChildren(); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < 4095; i += 1) fragment.appendChild(document.createElement('span')); + const button = document.createElement('button'); + button.id = 'after-node-budget'; + button.textContent = 'Too late'; + fragment.appendChild(button); + document.body.appendChild(fragment); + true; + """ + ) + + let result = try await snapshot(panel) + + XCTAssertEqual(TerminalController.v2BrowserSnapshotNodeVisitLimit, 4_096) + XCTAssertEqual(result["visited_nodes"] as? Int, 4_096) + XCTAssertEqual(result["node_limit"] as? Int, 4_096) + XCTAssertEqual(result["truncated"] as? Bool, true) + XCTAssertEqual(reasons(in: result), ["node_limit"]) + XCTAssertFalse(try entries(in: result).contains { $0["selector"] as? String == "#after-node-budget" }) + } + + func testCursorModeCannotRestartTraversalBeyondTheNodeBudget() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.replaceChildren(); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < 4095; i += 1) fragment.appendChild(document.createElement('span')); + const cursorOnly = document.createElement('div'); + cursorOnly.id = 'cursor-after-budget'; + cursorOnly.style.cursor = 'pointer'; + cursorOnly.textContent = 'cursor'; + fragment.appendChild(cursorOnly); + document.body.appendChild(fragment); + true; + """ + ) + + let withoutCursor = try await snapshot(panel, includeCursor: false) + let withCursor = try await snapshot(panel, includeCursor: true) + + XCTAssertEqual(withoutCursor["visited_nodes"] as? Int, 4_096) + XCTAssertEqual(withCursor["visited_nodes"] as? Int, 4_096) + XCTAssertEqual(reasons(in: withoutCursor), ["node_limit"]) + XCTAssertEqual(reasons(in: withCursor), ["node_limit"]) + XCTAssertFalse(try entries(in: withCursor).contains { $0["selector"] as? String == "#cursor-after-budget" }) + } + + func testSnapshotClampsRequestedDepthToTheNamedMaximum() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + let parent = document.body; + for (let i = 0; i < 64; i += 1) { + const child = document.createElement('div'); + parent.appendChild(child); + parent = child; + } + const tooDeep = document.createElement('button'); + tooDeep.id = 'past-max-depth'; + tooDeep.textContent = 'too deep'; + parent.appendChild(tooDeep); + true; + """ + ) + + let result = try await snapshot(panel, maxDepth: .max) + + XCTAssertEqual(TerminalController.v2BrowserSnapshotMaxDepth, 64) + XCTAssertFalse(try entries(in: result).contains { $0["selector"] as? String == "#past-max-depth" }) + XCTAssertEqual( + result["text_truncated"] as? Bool, + true, + "Skipping text below the requested depth must be reported as text truncation" + ) + } + + func testScopedSnapshotNeverTraversesOrSerializesLaterSiblings() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + let expectedNodeCountValue = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` +
+ +

Scoped text marker

+
+
+ +
+ `; + window.__programaLaterSiblingTouches = 0; + const laterSibling = document.getElementById('later-sibling'); + laterSibling.getBoundingClientRect = function() { + window.__programaLaterSiblingTouches += 1; + return { x: 0, y: 0, width: 10, height: 10, top: 0, right: 10, bottom: 10, left: 0 }; + }; + const scopeRoot = document.getElementById('snapshot-scope'); + const walker = document.createTreeWalker(scopeRoot, NodeFilter.SHOW_ALL); + let scopedNodeCount = 1; + while (walker.nextNode()) scopedNodeCount += 1; + scopedNodeCount; + """ + ) + let expectedNodeCount = try XCTUnwrap(expectedNodeCountValue as? Int) + let expectedScopedHTMLValue = try await panel.evaluateJavaScript("document.getElementById('snapshot-scope').outerHTML") as? String + let expectedScopedHTML = try XCTUnwrap(expectedScopedHTMLValue) + + let result = try await snapshot(panel, scopeSelector: "#snapshot-scope") + let sentinelValue = try await panel.evaluateJavaScript("window.__programaLaterSiblingTouches") + let returnedEntries = try entries(in: result) + let text = try XCTUnwrap(result["text"] as? String) + let html = try XCTUnwrap(result["html"] as? String) + + XCTAssertEqual(sentinelValue as? Int, 0, "A scoped traversal must not touch a later sibling") + XCTAssertEqual(result["visited_nodes"] as? Int, expectedNodeCount) + XCTAssertTrue(returnedEntries.contains { $0["selector"] as? String == "#scoped-button" }) + XCTAssertFalse(returnedEntries.contains { $0["selector"] as? String == "#later-button" }) + XCTAssertTrue(text.contains("Scoped button")) + XCTAssertTrue(text.contains("Scoped text marker")) + XCTAssertFalse(text.contains("Later sibling marker")) + XCTAssertEqual(html, expectedScopedHTML, "A scoped snapshot must serialize only the selected subtree") + XCTAssertTrue(html.hasPrefix("
")) + XCTAssertFalse(html.contains(" + +
+
+ +
+ `; + true; + """ + ) + let expectedHTMLValue = try await panel.evaluateJavaScript("document.getElementById('a').outerHTML") as? String + let expectedHTML = try XCTUnwrap(expectedHTMLValue) + + let result = try await snapshot(panel, scopeSelector: "#a, #b") + let returnedEntries = try entries(in: result) + let childEntry = try XCTUnwrap(returnedEntries.first { $0["name"] as? String == "A child" }) + let childSelector = try XCTUnwrap(childEntry["selector"] as? String) + let selectorLiteral = try javaScriptStringLiteral(childSelector) + let resolvedIdentity = try await panel.evaluateJavaScript( + "document.querySelector(\(selectorLiteral))?.dataset.identity || null" + ) + + XCTAssertEqual(result["html"] as? String, expectedHTML) + XCTAssertEqual(resolvedIdentity as? String, "selected-child") + XCTAssertNotEqual(resolvedIdentity as? String, "selected-root") + XCTAssertNotEqual(resolvedIdentity as? String, "other-root") + XCTAssertNotEqual(resolvedIdentity as? String, "other-child") + } + + func testSnapshotCollectorIgnoresCompromisedPageWorldCSSEscape() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + CSS.escape = function() { return 'attacker-selector'; }; + true; + """ + ) + let compromisedEscape = try await panel.evaluateJavaScript("CSS.escape('harmless-known-input')") + XCTAssertEqual( + compromisedEscape as? String, + "attacker-selector", + "The page-world mutation must be active before testing the isolated collector" + ) + + let result = try productionSnapshot(panel) + let returnedEntries = try entries(in: result) + let safeEntry = try XCTUnwrap(returnedEntries.first { $0["name"] as? String == "Safe" }) + + XCTAssertEqual( + safeEntry["selector"] as? String, + "#safe-selector", + "Page JavaScript must not be able to redirect a snapshot ref by replacing CSS.escape" + ) + } + + func testDuplicateIDSelectorStillResolvesToTheVisibleElementThatProducedTheEntry() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + const hidden = document.createElement('button'); + hidden.id = 'duplicate'; + hidden.dataset.identity = 'hidden-first'; + hidden.style.display = 'none'; + hidden.textContent = 'Hidden duplicate'; + document.body.appendChild(hidden); + const visible = document.createElement('button'); + visible.id = 'duplicate'; + visible.dataset.identity = 'visible-second'; + visible.textContent = 'Visible duplicate'; + document.body.appendChild(visible); + true; + """ + ) + + let result = try await snapshot(panel) + let visibleEntry = try XCTUnwrap(try entries(in: result).first { $0["name"] as? String == "Visible duplicate" }) + let selector = try XCTUnwrap(visibleEntry["selector"] as? String) + let selectorLiteral = try javaScriptStringLiteral(selector) + let resolvedIdentity = try await panel.evaluateJavaScript( + "document.querySelector(\(selectorLiteral))?.dataset.identity || null" + ) + + XCTAssertNotEqual(selector, "#duplicate", "A duplicate ID is not an identity-safe selector") + XCTAssertEqual(resolvedIdentity as? String, "visible-second") + } + + func testSnapshotAccessibleNamesIncludeNestedTextAndNestedLabelledContent() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` + +
Account settings
+ + `; + true; + """ + ) + + let result = try await snapshot(panel) + let returnedEntries = try entries(in: result) + let nestedName = returnedEntries.first { $0["selector"] as? String == "#nested-name" }?["name"] as? String + let labelledName = returnedEntries.first { $0["selector"] as? String == "#labelled-button" }?["name"] as? String + + XCTAssertEqual(nestedName, "Save") + XCTAssertEqual(labelledName, "Account settings") + } + + func testSnapshotPageTextExcludesNonVisibleAndNonContentTextWithElementBoundaries() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` +

Hello

World

+ + +
hidden-marker
+
Visible marker
+ `; + true; + """ + ) + + let result = try await snapshot(panel) + let text = try XCTUnwrap(result["text"] as? String) + let normalized = text.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ") + + XCTAssertEqual(normalized, "Hello World Visible marker") + XCTAssertNotEqual(normalized, "HelloWorld Visible marker") + XCTAssertFalse(normalized.contains("script-marker")) + XCTAssertFalse(normalized.contains("style-marker")) + XCTAssertFalse(normalized.contains("hidden-marker")) + } + + func testSelectedSameOriginFrameSnapshotUsesTheChildDocumentURLStylesAndNames() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { + TerminalController.shared.v2BrowserFrameSelectorBySurface.removeValue(forKey: panel.id) + panel.close() + } + let frameStateValue = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + const frame = document.createElement('iframe'); + frame.id = 'selected-frame'; + document.body.appendChild(frame); + frame.contentWindow.location.hash = 'selected-child-document'; + const childDocument = frame.contentDocument; + childDocument.open(); + childDocument.write('Child title'); + childDocument.close(); + ({ url: String(childDocument.location.href), title: childDocument.title }); + """ + ) as? [String: Any] + let frameState = try XCTUnwrap(frameStateValue) + let expectedURL = try XCTUnwrap(frameState["url"] as? String) + TerminalController.shared.v2BrowserFrameSelectorBySurface[panel.id] = "#selected-frame" + + let result = try productionSnapshot(panel) + let returnedEntries = try entries(in: result) + let childEntry = try XCTUnwrap(returnedEntries.first { $0["name"] as? String == "Child action" }) + let selector = try XCTUnwrap(childEntry["selector"] as? String) + let selectorLiteral = try javaScriptStringLiteral(selector) + let resolvedIdentity = try await panel.evaluateJavaScript( + "document.getElementById('selected-frame').contentDocument.querySelector(\(selectorLiteral))?.dataset.identity || null" + ) + + XCTAssertEqual(result["url"] as? String, expectedURL) + XCTAssertEqual(result["title"] as? String, "Child title") + XCTAssertEqual(childEntry["role"] as? String, "button") + XCTAssertEqual(resolvedIdentity as? String, "child-button") + } + + func testOversizedSelectorIsSkippedWithoutRetargetingALaterButton() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + const oversized = document.createElement('button'); + oversized.id = 'x'.repeat(16385); + oversized.textContent = 'oversized'; + document.body.appendChild(oversized); + const normal = document.createElement('button'); + normal.id = 'normal-after-oversized'; + normal.textContent = 'normal'; + document.body.appendChild(normal); + true; + """ + ) + + let result = try await snapshot(panel) + let returnedEntries = try entries(in: result) + let selectors = returnedEntries.compactMap { $0["selector"] as? String } + + XCTAssertEqual(result["selector_byte_limit"] as? Int, 16_384) + XCTAssertEqual(result["selector_skipped_count"] as? Int, 1) + XCTAssertTrue(reasons(in: result).contains("selector_byte_limit")) + XCTAssertEqual(selectors, ["#normal-after-oversized"]) + XCTAssertEqual(selectors[0].utf8.count, "#normal-after-oversized".utf8.count) + } + + func testMultibyteAccessibleNameTruncatesAtAValidUTF8Boundary() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + const button = document.createElement('button'); + button.id = 'multibyte-name'; + button.setAttribute('aria-label', 'é'.repeat(600)); + document.body.appendChild(button); + true; + """ + ) + + let result = try await snapshot(panel) + let name = try XCTUnwrap(try entries(in: result).first?["name"] as? String) + + XCTAssertEqual(result["name_byte_limit"] as? Int, 1_024) + XCTAssertEqual(result["name_truncated_count"] as? Int, 1) + XCTAssertTrue(reasons(in: result).contains("name_byte_limit")) + XCTAssertEqual(name, String(repeating: "é", count: 512)) + XCTAssertEqual(name.utf8.count, 1_024) + } + + func testOversizedRolesFallBackToImplicitRoleOrSkipInsteadOfTruncating() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + // The snapshot's visibility check requires a positive layout rect. A zero-size + // WKWebView (the default for a panel that has never been placed in a window) gives + // block-level elements a 0px width, so give it a real viewport before laying out + // the block-level `invalidDiv` below. + panel.webView.frame = NSRect(x: 0, y: 0, width: 800, height: 600) + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + const implicitButton = document.createElement('button'); + implicitButton.id = 'implicit-role-fallback'; + implicitButton.setAttribute('role', 'button'.repeat(20)); + implicitButton.textContent = 'button'; + document.body.appendChild(implicitButton); + const invalidDiv = document.createElement('div'); + invalidDiv.id = 'invalid-role-skip'; + invalidDiv.setAttribute('role', 'link'.repeat(20)); + invalidDiv.textContent = 'div'; + document.body.appendChild(invalidDiv); + true; + """ + ) + + let result = try await snapshot(panel) + let returnedEntries = try entries(in: result) + + XCTAssertEqual(result["role_byte_limit"] as? Int, 64) + XCTAssertEqual(result["role_skipped_count"] as? Int, 1) + XCTAssertTrue(reasons(in: result).contains("role_byte_limit")) + XCTAssertEqual(returnedEntries.count, 1) + XCTAssertEqual(returnedEntries[0]["selector"] as? String, "#implicit-role-fallback") + XCTAssertEqual(returnedEntries[0]["role"] as? String, "button") + } + + func testAggregateEntryBytesKeepOnlyTheDeterministicPreorderPrefix() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + const fragment = document.createDocumentFragment(); + for (let i = 0; i < 300; i += 1) { + const button = document.createElement('button'); + button.id = 'entry-' + i; + button.setAttribute('aria-label', 'n'.repeat(1024)); + fragment.appendChild(button); + } + document.body.appendChild(fragment); + true; + """ + ) + + let result = try await snapshot(panel) + let returnedEntries = try entries(in: result) + let accountedBytes = returnedEntries.reduce(into: 0) { total, entry in + total += ((entry["selector"] as? String) ?? "").utf8.count + total += ((entry["name"] as? String) ?? "").utf8.count + total += ((entry["role"] as? String) ?? "").utf8.count + } + + XCTAssertEqual(result["entry_byte_limit"] as? Int, 262_144) + XCTAssertEqual(result["entry_bytes"] as? Int, accountedBytes) + XCTAssertLessThanOrEqual(accountedBytes, 262_144) + XCTAssertLessThanOrEqual(returnedEntries.count, 256) + XCTAssertTrue(reasons(in: result).contains("entry_byte_limit")) + XCTAssertEqual( + returnedEntries.compactMap { $0["selector"] as? String }, + (0 ..< returnedEntries.count).map { "#entry-\($0)" } + ) + } + + func testGeneratedSnapshotBoundsPageStringsAndPreservesExactPrefixes() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + + // WebKit refuses to grow the URL of the WKWebView's initial "about:blank" document via + // `location.hash`/`history.replaceState` (SecurityError: session history URL cannot + // change from an opaque initial document). Commit a real navigation with a long-path + // base URL instead so `document.location.href` is genuinely long, then poll for that + // navigation to land before mutating title/body content. + let longPath = String(repeating: "u", count: 17000) + let longBaseURL = try XCTUnwrap(URL(string: "https://example.com/\(longPath)")) + panel.webView.loadHTMLString("", baseURL: longBaseURL) + for _ in 0 ..< 200 { + let currentHref = try await panel.evaluateJavaScript("String(location.href)") as? String + if currentHref?.hasPrefix("https://example.com/") == true { break } + try await Task.sleep(nanoseconds: 10_000_000) + } + + _ = try await panel.evaluateJavaScript( + """ + document.title = 't'.repeat(1100); + document.body.textContent = 'b'.repeat(1100000); + true; + """ + ) + + let evaluatedURL = try await panel.evaluateJavaScript("String(location.href)") + let evaluatedHTML = try await panel.evaluateJavaScript("String(document.documentElement.outerHTML)") + let fullURL = try XCTUnwrap(evaluatedURL as? String) + let fullHTML = try XCTUnwrap(evaluatedHTML as? String) + let result = try await snapshot(panel) + let title = try XCTUnwrap(result["title"] as? String) + let url = try XCTUnwrap(result["url"] as? String) + let text = try XCTUnwrap(result["text"] as? String) + let html = try XCTUnwrap(result["html"] as? String) + + XCTAssertEqual(title, String(String(repeating: "t", count: 1100).prefix(1_024))) + XCTAssertEqual(url, String(fullURL.prefix(16_384))) + XCTAssertEqual(text, String(String(repeating: "b", count: 1_100_000).prefix(262_144))) + XCTAssertEqual(html, String(fullHTML.prefix(1_048_576))) + XCTAssertEqual(title.utf8.count, 1_024) + XCTAssertEqual(url.utf8.count, 16_384) + XCTAssertEqual(text.count, 262_144) + XCTAssertLessThanOrEqual(html.count, 1_048_576) + XCTAssertTrue(reasons(in: result).contains("title_byte_limit")) + XCTAssertTrue(reasons(in: result).contains("url_byte_limit")) + XCTAssertEqual(result["text_truncated"] as? Bool, true) + XCTAssertEqual(result["html_truncated"] as? Bool, true) + } + + func testGeneratedSnapshotBoundsHugeCommentAttributeAndTagOutput() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + let limit = TerminalController.v2BrowserSnapshotHTMLCharacterLimit + + try await assertGeneratedHTMLIsBounded( + panel, + setupScript: """ + document.body.replaceChildren(document.createComment('c'.repeat(\(limit + 256)))); + true; + """ + ) + try await assertGeneratedHTMLIsBounded( + panel, + setupScript: """ + document.body.innerHTML = ''; + const attributed = document.createElement('div'); + attributed.setAttribute('data-huge', 'a'.repeat(\(limit + 256))); + document.body.appendChild(attributed); + true; + """ + ) + try await assertGeneratedHTMLIsBounded( + panel, + setupScript: """ + document.body.innerHTML = ''; + const fragment = document.createDocumentFragment(); + const tag = 'snapshot-' + 'x'.repeat(240); + for (let index = 0; index < 2_200; index += 1) fragment.appendChild(document.createElement(tag)); + document.body.appendChild(fragment); + true; + """ + ) + } + + func testNestedScopedSelectorListKeepsEachEntryBoundToItsOriginatingElement() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` +
+
+ +
+ `; + true; + """ + ) + + let result = try await snapshot(panel, scopeSelector: "#a, #b") + let targetEntry = try XCTUnwrap( + try entries(in: result).first { $0["name"] as? String == "Direct target" } + ) + let selector = try XCTUnwrap(targetEntry["selector"] as? String) + let selectorLiteral = try javaScriptStringLiteral(selector) + let resolvedIdentity = try await panel.evaluateJavaScript( + "document.querySelector(\(selectorLiteral))?.dataset.identity || null" + ) + + XCTAssertEqual(resolvedIdentity as? String, "direct-target") + XCTAssertNotEqual(resolvedIdentity as? String, "nested-wrong") + } + + func testSnapshotStopsInspectingAggregateWhitespaceBeforeUnboundedLateText() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.replaceChildren(); + const fragment = document.createDocumentFragment(); + for (let index = 0; index < 1024; index += 1) { + fragment.appendChild(document.createTextNode(' '.repeat(1025))); + } + const marker = document.createElement('span'); + marker.textContent = 'late-visible-marker'; + fragment.appendChild(marker); + document.body.appendChild(fragment); + true; + """ + ) + + let result = try await snapshot(panel) + let text = try XCTUnwrap(result["text"] as? String) + + XCTAssertLessThan(result["visited_nodes"] as? Int ?? .max, 4_096) + XCTAssertEqual(result["text_truncated"] as? Bool, true) + XCTAssertFalse(text.contains("late-visible-marker")) + XCTAssertEqual(result["text_inspection_limit"] as? Int, 1_048_832) + let inspectedUnits = try XCTUnwrap(result["text_inspected_units"] as? Int) + XCTAssertGreaterThan(inspectedUnits, 0) + XCTAssertLessThanOrEqual(inspectedUnits, 1_048_832) + XCTAssertTrue(reasons(in: result).contains("text_inspection_limit")) + } + + func testEscapedNullIDSelectorCannotResolveAnEarlierReplacementCharacterElement() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.replaceChildren(); + const replacement = document.createElement('button'); + replacement.id = '\u{FFFD}'; + replacement.dataset.identity = 'replacement-character'; + replacement.textContent = 'Replacement character'; + document.body.appendChild(replacement); + const nul = document.createElement('button'); + nul.id = String.fromCharCode(0); + nul.dataset.identity = 'nul-target'; + nul.textContent = 'NUL target'; + document.body.appendChild(nul); + true; + """ + ) + + let result = try await snapshot(panel) + let targetEntry = try XCTUnwrap(try entries(in: result).first { $0["name"] as? String == "NUL target" }) + let selector = try XCTUnwrap(targetEntry["selector"] as? String) + let selectorLiteral = try javaScriptStringLiteral(selector) + let resolvedIdentity = try await panel.evaluateJavaScript( + "document.querySelector(\(selectorLiteral))?.dataset.identity || null" + ) + + XCTAssertEqual(resolvedIdentity as? String, "nul-target") + XCTAssertNotEqual(resolvedIdentity as? String, "replacement-character") + } + + func testSnapshotTextPreservesInlineRunsAuthoredWhitespaceAndBlockBoundaries() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = 'programa rocks

First block

Second block

'; + true; + """ + ) + + let result = try await snapshot(panel) + + XCTAssertEqual(result["text"] as? String, "programa rocks First block Second block") + } + + func testContentNamesSuppressHiddenDescendantsWhileExplicitHiddenLabelsTakePrecedence() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` + + + + `; + true; + """ + ) + + let result = try await snapshot(panel) + let returnedEntries = try entries(in: result) + let contentName = returnedEntries.first { $0["selector"] as? String == "#content-name" }?["name"] as? String + let labelledName = returnedEntries.first { $0["selector"] as? String == "#label-precedence" }?["name"] as? String + + XCTAssertEqual(contentName, "Visible nested text") + XCTAssertEqual(labelledName, "Explicit hidden label") + } + + func testSnapshotPreservesInertTemplateMarkupWithoutExposingItsText() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + true; + """ + ) + let nativeHTMLValue = try await panel.evaluateJavaScript("String(document.documentElement.outerHTML)") as? String + let nativeHTML = try XCTUnwrap(nativeHTMLValue) + + let result = try await snapshot(panel) + let html = try XCTUnwrap(result["html"] as? String) + let text = try XCTUnwrap(result["text"] as? String) + + XCTAssertEqual(html, nativeHTML) + XCTAssertTrue(html.contains("
Deferred
")) + XCTAssertFalse(text.contains("Deferred"), "Inert template content must not become visible page text") + } + + func testExplicitVisibilityHiddenLabelIncludesDescendantsWhileOrdinaryNamesSuppressThem() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` + + + + `; + true; + """ + ) + + let result = try await snapshot(panel) + let returnedEntries = try entries(in: result) + let labelledName = returnedEntries.first { $0["selector"] as? String == "#labelled" }?["name"] as? String + let ordinaryName = returnedEntries.first { $0["selector"] as? String == "#ordinary" }?["name"] as? String + + XCTAssertEqual(labelledName, "Secret") + XCTAssertEqual(ordinaryName, "Public") + } + + func testMixedNamespaceSnapshotPreservesNativeHTMLAndForeignElementIdentity() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ` + + + + + + + + `; + true; + """ + ) + let nativeHTMLValue = try await panel.evaluateJavaScript("String(document.documentElement.outerHTML)") as? String + let nativeHTML = try XCTUnwrap(nativeHTMLValue) + + let result = try await snapshot(panel) + let foreignEntry = try XCTUnwrap(try entries(in: result).first { $0["name"] as? String == "Foreign action" }) + let selector = try XCTUnwrap(foreignEntry["selector"] as? String) + let selectorLiteral = try javaScriptStringLiteral(selector) + let resolvedIdentity = try await panel.evaluateJavaScript( + "document.querySelector(\(selectorLiteral))?.dataset.identity || null" + ) + + XCTAssertEqual(result["html"] as? String, nativeHTML) + XCTAssertTrue(nativeHTML.contains("linearGradient")) + XCTAssertEqual(resolvedIdentity as? String, "foreign-origin") + } + + func testSelectedFrameRemovalReturnsFrameUnavailableInsteadOfTopDocumentContent() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { + TerminalController.shared.v2BrowserFrameSelectorBySurface.removeValue(forKey: panel.id) + panel.close() + } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = '
Top document must not leak
'; + document.getElementById('removed-frame').remove(); + true; + """ + ) + TerminalController.shared.v2BrowserFrameSelectorBySurface[panel.id] = "#removed-frame" + let script = TerminalController.shared.v2BrowserSnapshotJavaScript( + interactiveOnly: false, + includeCursor: false, + compact: false, + maxDepth: 64, + scopeSelector: nil + ) + + let outcome = TerminalController.shared.v2BrowserCollectSnapshotJavaScriptOutcome( + webView: panel.webView, + surfaceId: panel.id, + script: script + ) + switch outcome { + case .frameUnavailable(let selector): + XCTAssertEqual(selector, "#removed-frame") + case .collected(let result): + XCTFail("A missing selected frame must not fall back to top-document content: \(result)") + case .failed(let message): + XCTFail("A missing selected frame must have a structured unavailable outcome, not a generic failure: \(message)") + } + } + + func testGeneratedSelectorActionFailsWhenSelectedFrameDisappearsInsteadOfClickingTopDocument() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { + TerminalController.shared.v2BrowserFrameSelectorBySurface.removeValue(forKey: panel.id) + panel.close() + } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + document.getElementById('shared-action').addEventListener('click', (event) => { + event.currentTarget.dataset.clicked = '1'; + }); + document.getElementById('selected-action-frame').remove(); + true; + """ + ) + TerminalController.shared.v2BrowserFrameSelectorBySurface[panel.id] = "#selected-action-frame" + + let outcome = TerminalController.shared.v2BrowserRunGeneratedSelectorAction( + webView: panel.webView, + surfaceId: panel.id, + selector: "#shared-action", + action: .click + ) + switch outcome { + case .frameUnavailable(let selector): + XCTAssertEqual(selector, "#selected-action-frame") + case .succeeded: + XCTFail("A removed selected frame must not let a generated action fall back to the top document") + case .elementNotFound: + XCTFail("The selected-frame failure must remain distinguishable from a missing element") + case .failed(let message): + XCTFail("The selected-frame failure must be structured, not generic: \(message)") + } + let topClickCount = try await panel.evaluateJavaScript( + "document.getElementById('shared-action').dataset.clicked" + ) + XCTAssertEqual(topClickCount as? String, "0") + } + + func testGeneratedElementRefActionIgnoresHostilePageWorldQuerySelectorOverride() async throws { + let panel = BrowserPanel(workspaceId: UUID()) + defer { + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: panel.id) + panel.close() + } + _ = try await panel.evaluateJavaScript( + """ + document.body.innerHTML = ''; + for (const button of document.querySelectorAll('button')) { + button.addEventListener('click', (event) => { event.currentTarget.dataset.clicked = '1'; }); + } + const attacker = document.getElementById('attacker-action'); + document.querySelector = function() { return attacker; }; + true; + """ + ) + let elementRef: String + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: panel.id, + selectors: ["#trusted-action"] + ) { + case .allocated(let refs): + elementRef = try XCTUnwrap(refs.first) + case .resourceExhausted: + return XCTFail("A fresh surface must have capacity for one trusted selector") + } + + let outcome = TerminalController.shared.v2BrowserRunGeneratedSelectorAction( + webView: panel.webView, + surfaceId: panel.id, + selector: elementRef, + action: .click + ) + guard case .succeeded = outcome else { + return XCTFail("The trusted generated action must succeed despite the page-world override: \(outcome)") + } + let clickStateValue = try await panel.evaluateJavaScript( + "({ trusted: document.getElementById('trusted-action').dataset.clicked, attacker: document.getElementById('attacker-action').dataset.clicked })" + ) as? [String: Any] + let clickState = try XCTUnwrap(clickStateValue) + + XCTAssertEqual(clickState["trusted"] as? String, "1") + XCTAssertEqual(clickState["attacker"] as? String, "0") + } + + func testOversizedFrameSelectorsAreRejectedForLiteralSelectionAndStateRestore() { + let selector = "#" + String(repeating: "f", count: 16_384) + XCTAssertGreaterThan(selector.utf8.count, TerminalController.v2BrowserElementRefSelectorByteLimit) + let selectedSurface = UUID() + let restoredSurface = UUID() + defer { + TerminalController.shared.v2BrowserFrameSelectorBySurface.removeValue(forKey: selectedSurface) + TerminalController.shared.v2BrowserFrameSelectorBySurface.removeValue(forKey: restoredSurface) + } + + for (surfaceId, source) in [ + (selectedSurface, TerminalController.V2BrowserFrameSelectorSource.frameSelect), + (restoredSurface, TerminalController.V2BrowserFrameSelectorSource.stateLoad), + ] { + let result = TerminalController.shared.v2BrowserApplyFrameSelector( + selector, + surfaceId: surfaceId, + source: source + ) + guard case .rejected(let limit) = result else { + return XCTFail("An oversized \(source) selector must be rejected") + } + XCTAssertEqual(limit, 16_384) + XCTAssertNil(TerminalController.shared.v2BrowserFrameSelectorBySurface[surfaceId]) + } + } +} + + @MainActor final class WindowBrowserHostViewTests: XCTestCase { private final class CapturingView: NSView { diff --git a/programaTests/ClaudeQuotaSnapshotParserTests.swift b/programaTests/ClaudeQuotaSnapshotParserTests.swift index ce6b061e..23a49b2b 100644 --- a/programaTests/ClaudeQuotaSnapshotParserTests.swift +++ b/programaTests/ClaudeQuotaSnapshotParserTests.swift @@ -150,6 +150,26 @@ final class ClaudeQuotaSnapshotParserTests: XCTestCase { ) } + func testSignedOutClaudeCLIExitStatusStillHidesTheProviderInsteadOfFailing() async throws { + let now = Date(timeIntervalSince1970: 1_785_168_986) + let fixture = try ClaudeUsageFixture.make( + authBody: #"print -r -- '{"loggedIn":false}'; exit 1"#, + cacheData: payload(updatedAt: String(Int(now.timeIntervalSince1970 * 1_000))) + ) + addTeardownBlock { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + let result = await ClaudeProviderUsageFetcher.fetchForTesting( + executableURL: fixture.executableURL, + cacheURL: fixture.cacheURL, + timeout: 0.5, + now: now + ) + + guard case .unavailable(.claude) = result else { + return XCTFail("The official CLI exits 1 when signed out; a parseable answer must hide the provider, got \(result)") + } + } + func testLoggedInClaudeWithAFreshRegularBoundedCacheIsAvailable() async throws { let now = Date(timeIntervalSince1970: 1_785_168_986) let fixture = try ClaudeUsageFixture.make( @@ -459,6 +479,73 @@ final class CodexUsageSnapshotParserTests: XCTestCase { ) } + func testLongLivedServerWithAnIdleStderrPipeDoesNotStallTheResponseReader() async throws { + let fake = try FakeCodexAppServer.make( + initializationResponse: #"{"jsonrpc":"2.0","id":0,"result":{}}"#, + responseDelay: 0.01, + lingerAfterResponses: 3 + ) + addTeardownBlock { try? FileManager.default.removeItem(at: fake.directoryURL) } + let clock = ContinuousClock() + let startedAt = clock.now + + let result = await CodexProviderUsageFetcher.fetchForTesting( + executableURL: fake.executableURL, + timeout: 1 + ) + + guard case let .available(snapshot) = result, snapshot.provider == .codex else { + return XCTFail("The real app server stays alive after answering; its responses must be read anyway, got \(result)") + } + XCTAssertLessThan(startedAt.duration(to: clock.now), .milliseconds(800)) + } + + func testConcurrentClaudeAndCodexProbesDoNotStarveEachOther() async throws { + let now = Date(timeIntervalSince1970: 1_785_168_986) + let claude = try ClaudeUsageFixture.make( + authBody: #"print -r -- '{"loggedIn":true}'"#, + cacheData: Data(""" + {"five_hour":{"used_percentage":17,"resets_at":"1785171600"}, + "seven_day":{"used_percentage":3,"resets_at":"1785664800"}, + "updated_at":\(Int(now.timeIntervalSince1970 * 1_000))} + """.utf8) + ) + let codex = try FakeCodexAppServer.make( + initializationResponse: #"{"jsonrpc":"2.0","id":0,"result":{}}"#, + responseDelay: 0.01, + lingerAfterResponses: 3 + ) + addTeardownBlock { + try? FileManager.default.removeItem(at: claude.directoryURL) + try? FileManager.default.removeItem(at: codex.directoryURL) + } + + let results = await withTaskGroup(of: ProviderUsageResult.self, returning: [ProviderUsageResult].self) { group in + group.addTask { + await ClaudeProviderUsageFetcher.fetchForTesting( + executableURL: claude.executableURL, + cacheURL: claude.cacheURL, + timeout: 1, + now: now + ) + } + group.addTask { + await CodexProviderUsageFetcher.fetchForTesting(executableURL: codex.executableURL, timeout: 1) + } + var collected: [ProviderUsageResult] = [] + for await result in group { + collected.append(result) + } + return collected + } + + for result in results { + guard case .available = result else { + return XCTFail("Both providers are probed together when the popover opens; neither may time out, got \(results)") + } + } + } + func testFastExitingServerStillReturnsTheFinalAccountAndRateLimitResponses() async throws { let fake = try FakeCodexAppServer.make( initializationResponse: #"{"jsonrpc":"2.0","id":0,"result":{}}"#, @@ -519,7 +606,8 @@ private struct FakeCodexAppServer { static func make( initializationResponse: String, - responseDelay: TimeInterval = 0.12 + responseDelay: TimeInterval = 0.12, + lingerAfterResponses: TimeInterval = 0 ) throws -> Self { try makeScript { eventsPath in """ @@ -543,6 +631,7 @@ private struct FakeCodexAppServer { fi print -r -- '{"jsonrpc":"2.0","id":1,"result":{"account":{"type":"chatgpt"}}}' print -r -- '{"jsonrpc":"2.0","id":2,"result":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":24,"windowDurationMins":300,"resetsAt":1785171600},"secondary":{"usedPercent":31,"windowDurationMins":10080,"resetsAt":1785664800}},"rateLimitsByLimitId":{}}}' + sleep \(lingerAfterResponses) """ } } diff --git a/programaTests/GhosttyConfigTests.swift b/programaTests/GhosttyConfigTests.swift index a2349748..a15edc4d 100644 --- a/programaTests/GhosttyConfigTests.swift +++ b/programaTests/GhosttyConfigTests.swift @@ -1259,9 +1259,6 @@ final class BrowserPanelPopupContextTests: XCTestCase { ) defer { popupWebView.window?.close() } - XCTAssertTrue( - popupWebView.configuration.processPool === panel.webView.configuration.processPool - ) XCTAssertTrue( popupWebView.configuration.websiteDataStore === panel.webView.configuration.websiteDataStore ) diff --git a/programaTests/ShortcutAndCommandPaletteTests.swift b/programaTests/ShortcutAndCommandPaletteTests.swift index 4f96a47d..418ade1d 100644 --- a/programaTests/ShortcutAndCommandPaletteTests.swift +++ b/programaTests/ShortcutAndCommandPaletteTests.swift @@ -1070,6 +1070,99 @@ final class UpdateChannelSettingsTests: XCTestCase { } +private final class UpdateRelaunchPreparationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var events: [String] = [] + private var operationRanOnMainThread = false + + func recordPrepared() { + lock.lock() + operationRanOnMainThread = Thread.isMainThread + events.append("prepared") + lock.unlock() + } + + func recordReturned() { + lock.lock() + events.append("returned") + lock.unlock() + } + + func record(_ event: String) { + lock.lock() + events.append(event) + lock.unlock() + } + + func snapshot() -> (events: [String], operationRanOnMainThread: Bool) { + lock.lock() + defer { lock.unlock() } + return (events, operationRanOnMainThread) + } +} + + +final class UpdateRelaunchPreparationTests: XCTestCase { + @MainActor + func testMainThreadCallCompletesPreparationBeforeReturning() { + var events: [String] = [] + var operationRanOnMainThread = false + + UpdateRelaunchPreparation.performSynchronously { + operationRanOnMainThread = Thread.isMainThread + events.append("prepared") + } + events.append("returned") + + XCTAssertEqual(events, ["prepared", "returned"]) + XCTAssertTrue(operationRanOnMainThread) + } + + @MainActor + func testBackgroundCallCompletesMainThreadPreparationBeforeReturning() async { + let recorder = UpdateRelaunchPreparationRecorder() + let completed = expectation(description: "background relaunch preparation completed") + + DispatchQueue.global(qos: .userInitiated).async { + UpdateRelaunchPreparation.performSynchronously { + recorder.recordPrepared() + } + recorder.recordReturned() + completed.fulfill() + } + + await fulfillment(of: [completed], timeout: 1) + + let snapshot = recorder.snapshot() + XCTAssertEqual(snapshot.events, ["prepared", "returned"]) + XCTAssertTrue(snapshot.operationRanOnMainThread) + } + + func testSynchronousPersistenceWriteDrainsEarlierQueuedWriteBeforeReturning() async { + let queue = DispatchQueue(label: "test.update-relaunch.persistence-order") + let recorder = UpdateRelaunchPreparationRecorder() + let completed = expectation(description: "synchronous persistence write completed") + + queue.suspend() + queue.async { + recorder.record("earlier") + } + DispatchQueue.global(qos: .userInitiated).async { + AppDelegate.performSessionPersistenceWrite(on: queue, synchronously: true) { + recorder.record("final") + } + recorder.recordReturned() + completed.fulfill() + } + queue.resume() + + await fulfillment(of: [completed], timeout: 1) + + XCTAssertEqual(recorder.snapshot().events, ["earlier", "final", "returned"]) + } +} + + final class UpdateSettingsTests: XCTestCase { func testApplyEnablesAutomaticChecksAndDailySchedule() { let defaults = makeDefaults() diff --git a/programaTests/TerminalAndGhosttyTests.swift b/programaTests/TerminalAndGhosttyTests.swift index e5f056aa..240712a8 100644 --- a/programaTests/TerminalAndGhosttyTests.swift +++ b/programaTests/TerminalAndGhosttyTests.swift @@ -4475,45 +4475,6 @@ final class TerminalControllerV2BrowserStateRestoreTests: XCTestCase { return url } - private func encodedState( - url: URL? = URL(string: "https://example.com/restored"), - cookies: [[String: Any]]? = nil, - localStorage: [String: String] = ["theme": "dark"], - sessionStorage: [String: String] = ["step": "1"], - frameSelector: String? = "#checkout", - limits: Limits? = nil - ) -> Result { - TerminalController.V2BrowserStateRestorer.encodeDocument( - url: url, - cookies: cookies ?? [[ - "name": "session", - "value": "token", - "domain": "example.com", - "path": "/", - ]], - storage: [ - "local": localStorage, - "session": sessionStorage, - ], - frameSelector: frameSelector, - limits: limits ?? constrainedLimits - ) - } - - private func encodedFailure( - _ result: Result, - file: StaticString = #filePath, - line: UInt = #line - ) -> TerminalController.V2BrowserStateRestoreFailure? { - switch result { - case .success: - XCTFail("Expected browser state save encoding to fail", file: file, line: line) - return nil - case .failure(let failure): - return failure - } - } - @discardableResult private func restore( _ fileURL: URL, @@ -4604,129 +4565,6 @@ final class TerminalControllerV2BrowserStateRestoreTests: XCTestCase { } } - func testSavedStateRoundTripsAtLoaderBoundaries() throws { - let cookies = (0 ..< constrainedLimits.cookieCountLimit).map { index in - ["name": "cookie-\(index)", "value": "v", "domain": "example.com", "path": "/"] - } - let localStorage = Dictionary( - uniqueKeysWithValues: (0 ..< constrainedLimits.storageEntryCountLimit).map { index in - ( - String(repeating: "k", count: constrainedLimits.storageKeyByteLimit - 1) + "\(index)", - String(repeating: "v", count: constrainedLimits.storageValueByteLimit) - ) - } - ) - let frameSelector = String(repeating: "f", count: constrainedLimits.frameSelectorByteLimit) - - let encoded: Data - switch encodedState( - cookies: cookies, - localStorage: localStorage, - sessionStorage: [:], - frameSelector: frameSelector - ) { - case .success(let data): - encoded = data - case .failure(let failure): - return XCTFail("A saved state at every loader boundary must encode: \(failure)") - } - - switch TerminalController.V2BrowserStateRestorer.prepare( - data: encoded, - limits: constrainedLimits - ) { - case .success(let prepared): - XCTAssertEqual(prepared.cookies.count, constrainedLimits.cookieCountLimit) - XCTAssertEqual(prepared.storage.local, localStorage) - XCTAssertEqual(prepared.frameSelector, frameSelector) - case .failure(let failure): - XCTFail("Every successful save must round-trip through the loader: \(failure)") - } - - let exactDocumentLimits = Limits( - documentByteLimit: encoded.count, - urlByteLimit: constrainedLimits.urlByteLimit, - cookieCountLimit: constrainedLimits.cookieCountLimit, - storageEntryCountLimit: constrainedLimits.storageEntryCountLimit, - storageKeyByteLimit: constrainedLimits.storageKeyByteLimit, - storageValueByteLimit: constrainedLimits.storageValueByteLimit, - frameSelectorByteLimit: constrainedLimits.frameSelectorByteLimit - ) - switch encodedState( - cookies: cookies, - localStorage: localStorage, - sessionStorage: [:], - frameSelector: frameSelector, - limits: exactDocumentLimits - ) { - case .success(let data): - XCTAssertEqual(data.count, encoded.count) - case .failure(let failure): - XCTFail("A state exactly at the document byte limit must save: \(failure)") - } - } - - func testStateSaveRejectsBlankAndOversizedDocumentsBeforeWriting() throws { - XCTAssertEqual(encodedFailure(encodedState(url: nil))?.code, .invalidURL) - XCTAssertEqual(encodedFailure(encodedState(url: URL(string: "about:blank")))?.code, .invalidURL) - let oversizedURL = try XCTUnwrap( - URL(string: "https://example.com/\(String(repeating: "u", count: constrainedLimits.urlByteLimit))") - ) - XCTAssertEqual(encodedFailure(encodedState(url: oversizedURL))?.code, .invalidURL) - - let tooManyCookies = (0 ... constrainedLimits.cookieCountLimit).map { index in - ["name": "cookie-\(index)", "value": "v", "domain": "example.com", "path": "/"] - } - XCTAssertEqual( - encodedFailure(encodedState(cookies: tooManyCookies))?.code, - .cookieLimitExceeded - ) - XCTAssertEqual( - encodedFailure(encodedState( - localStorage: Dictionary( - uniqueKeysWithValues: (0 ... constrainedLimits.storageEntryCountLimit).map { ("k\($0)", "v") } - ), - sessionStorage: [:] - ))?.code, - .storageEntryLimitExceeded - ) - XCTAssertEqual( - encodedFailure(encodedState( - localStorage: [String(repeating: "k", count: constrainedLimits.storageKeyByteLimit + 1): "v"], - sessionStorage: [:] - ))?.code, - .storageKeyTooLarge - ) - XCTAssertEqual( - encodedFailure(encodedState( - localStorage: ["key": String(repeating: "v", count: constrainedLimits.storageValueByteLimit + 1)], - sessionStorage: [:] - ))?.code, - .storageValueTooLarge - ) - XCTAssertEqual( - encodedFailure(encodedState( - frameSelector: String(repeating: "f", count: constrainedLimits.frameSelectorByteLimit + 1) - ))?.code, - .frameSelectorTooLarge - ) - - let validData = try XCTUnwrap(try? encodedState().get()) - let tooSmallDocumentLimits = Limits( - documentByteLimit: validData.count - 1, - urlByteLimit: constrainedLimits.urlByteLimit, - cookieCountLimit: constrainedLimits.cookieCountLimit, - storageEntryCountLimit: constrainedLimits.storageEntryCountLimit, - storageKeyByteLimit: constrainedLimits.storageKeyByteLimit, - storageValueByteLimit: constrainedLimits.storageValueByteLimit, - frameSelectorByteLimit: constrainedLimits.frameSelectorByteLimit - ) - XCTAssertEqual( - encodedFailure(encodedState(limits: tooSmallDocumentLimits))?.code, - .documentTooLarge - ) - } - func testRestoreInstallsCookiesThenWaitsForMatchingCommitAndFinishBeforePageState() throws { let recorder = RestoreRecorder() let navigationID = UUID() @@ -5118,44 +4956,6 @@ final class TerminalControllerV2BrowserStateRestoreTests: XCTestCase { coordinator.release(secondLease) } - func testStorageMutationCallbackKeepsSharedStoreLeasedAfterRestoreTimeout() throws { - let coordinator = TerminalController.V2BrowserStateRestoreLeaseCoordinator() - let store = WKWebsiteDataStore.nonPersistent() - let storeID = ObjectIdentifier(store) - let firstLease = try XCTUnwrap( - coordinator.acquire(dataStoreID: storeID, generation: UUID()) - ) - - XCTAssertTrue(coordinator.beginPendingMutation(firstLease), "Cookie writes are in flight") - XCTAssertTrue(coordinator.beginPendingMutation(firstLease), "The storage JavaScript callback is in flight") - coordinator.release(firstLease) - XCTAssertEqual( - coordinator.state(dataStoreID: storeID), - .taintedByUndrainedMutationCallbacks, - "If WebKit never invokes the callback, the store must remain explicitly tainted and busy rather than overlap a later restore" - ) - - XCTAssertTrue(coordinator.endPendingMutation(firstLease), "Cookie callbacks drained") - XCTAssertEqual(coordinator.state(dataStoreID: storeID), .taintedByUndrainedMutationCallbacks) - XCTAssertNil( - coordinator.acquire(dataStoreID: storeID, generation: UUID()), - "A timed-out restore must keep the store fenced until its late storage callback drains" - ) - - XCTAssertTrue(coordinator.endPendingMutation(firstLease), "The late storage callback drained") - XCTAssertEqual(coordinator.state(dataStoreID: storeID), .available) - let secondGeneration = UUID() - let secondLease = try XCTUnwrap( - coordinator.acquire(dataStoreID: storeID, generation: secondGeneration) - ) - XCTAssertFalse( - coordinator.endPendingMutation(firstLease), - "A duplicate storage callback must not release a newer restore lease" - ) - XCTAssertTrue(coordinator.isValid(secondLease, currentGeneration: secondGeneration)) - coordinator.release(secondLease) - } - func testRemoteProxyRestorePreservesLogicalOriginAndVerifiesAliasExecutionOrigin() throws { let logicalURLs = try [ "http://127.0.0.1:3000/state", @@ -5224,42 +5024,497 @@ final class TerminalControllerV2BrowserStateRestoreTests: XCTestCase { "Ambiguous duplicate identities must be resolved before an unordered cookie-store batch begins" ) } -} - -// MARK: - V2 Browser Automation Ref Invariants (audit M6a / M8) - -@MainActor -final class TerminalControllerV2RefInvariantTests: XCTestCase { - /// M6a: an element ref (@eN) allocated before a navigation must not silently re-resolve - /// against the new page's DOM after the surface navigates — it should report a structured - /// `stale_element` error instead. - func testElementRefResolvesUntilSurfaceNavigatesThenReportsStale() { - let surfaceId = UUID() - let ref = TerminalController.shared.v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: "#foo") - - // Before any navigation, the ref resolves normally. - XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: surfaceId), "#foo") - - // Simulate a committed main-frame navigation on that surface (BrowserPanel's - // navigationDelegate.didCommit calls this in production). - TerminalController.shared.v2BrowserBumpNavigationGeneration(forSurface: surfaceId) - - // The pre-navigation ref must no longer resolve... - XCTAssertNil(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: surfaceId)) - - // ...and the error surfaced must specifically be stale_element, not a generic not_found, - // so callers can distinguish "this ref is dead" from "this ref never existed". - switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: surfaceId) { - case .err(let code, _, let data): - XCTAssertEqual(code, "stale_element") - XCTAssertEqual(data as? [String: String], ["ref": ref]) - case .ok: - XCTFail("expected an error result for a stale ref") - } - // A ref allocated *after* the navigation on the same surface resolves normally again. - let freshRef = TerminalController.shared.v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: "#bar") - XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(freshRef, surfaceId: surfaceId), "#bar") + private func encodedState( + url: URL? = URL(string: "https://example.com/restored"), + cookies: [[String: Any]]? = nil, + localStorage: [String: String] = ["theme": "dark"], + sessionStorage: [String: String] = ["step": "1"], + frameSelector: String? = "#checkout", + limits: Limits? = nil + ) -> Result { + TerminalController.V2BrowserStateRestorer.encodeDocument( + url: url, + cookies: cookies ?? [[ + "name": "session", + "value": "token", + "domain": "example.com", + "path": "/", + ]], + storage: [ + "local": localStorage, + "session": sessionStorage, + ], + frameSelector: frameSelector, + limits: limits ?? constrainedLimits + ) + } + + private func encodedFailure( + _ result: Result, + file: StaticString = #filePath, + line: UInt = #line + ) -> TerminalController.V2BrowserStateRestoreFailure? { + switch result { + case .success: + XCTFail("Expected browser state save encoding to fail", file: file, line: line) + return nil + case .failure(let failure): + return failure + } + } + + func testSavedStateRoundTripsAtLoaderBoundaries() throws { + let cookies = (0 ..< constrainedLimits.cookieCountLimit).map { index in + ["name": "cookie-\(index)", "value": "v", "domain": "example.com", "path": "/"] + } + let localStorage = Dictionary( + uniqueKeysWithValues: (0 ..< constrainedLimits.storageEntryCountLimit).map { index in + ( + String(repeating: "k", count: constrainedLimits.storageKeyByteLimit - 1) + "\(index)", + String(repeating: "v", count: constrainedLimits.storageValueByteLimit) + ) + } + ) + let frameSelector = String(repeating: "f", count: constrainedLimits.frameSelectorByteLimit) + + let encoded: Data + switch encodedState( + cookies: cookies, + localStorage: localStorage, + sessionStorage: [:], + frameSelector: frameSelector + ) { + case .success(let data): + encoded = data + case .failure(let failure): + return XCTFail("A saved state at every loader boundary must encode: \(failure)") + } + + switch TerminalController.V2BrowserStateRestorer.prepare( + data: encoded, + limits: constrainedLimits + ) { + case .success(let prepared): + XCTAssertEqual(prepared.cookies.count, constrainedLimits.cookieCountLimit) + XCTAssertEqual(prepared.storage.local, localStorage) + XCTAssertEqual(prepared.frameSelector, frameSelector) + case .failure(let failure): + XCTFail("Every successful save must round-trip through the loader: \(failure)") + } + + let exactDocumentLimits = Limits( + documentByteLimit: encoded.count, + urlByteLimit: constrainedLimits.urlByteLimit, + cookieCountLimit: constrainedLimits.cookieCountLimit, + storageEntryCountLimit: constrainedLimits.storageEntryCountLimit, + storageKeyByteLimit: constrainedLimits.storageKeyByteLimit, + storageValueByteLimit: constrainedLimits.storageValueByteLimit, + frameSelectorByteLimit: constrainedLimits.frameSelectorByteLimit + ) + switch encodedState( + cookies: cookies, + localStorage: localStorage, + sessionStorage: [:], + frameSelector: frameSelector, + limits: exactDocumentLimits + ) { + case .success(let data): + XCTAssertEqual(data.count, encoded.count) + case .failure(let failure): + XCTFail("A state exactly at the document byte limit must save: \(failure)") + } + } + + func testStateSaveRejectsBlankAndOversizedDocumentsBeforeWriting() throws { + XCTAssertEqual(encodedFailure(encodedState(url: nil))?.code, .invalidURL) + XCTAssertEqual(encodedFailure(encodedState(url: URL(string: "about:blank")))?.code, .invalidURL) + let oversizedURL = try XCTUnwrap( + URL(string: "https://example.com/\(String(repeating: "u", count: constrainedLimits.urlByteLimit))") + ) + XCTAssertEqual(encodedFailure(encodedState(url: oversizedURL))?.code, .invalidURL) + + let tooManyCookies = (0 ... constrainedLimits.cookieCountLimit).map { index in + ["name": "cookie-\(index)", "value": "v", "domain": "example.com", "path": "/"] + } + XCTAssertEqual( + encodedFailure(encodedState(cookies: tooManyCookies))?.code, + .cookieLimitExceeded + ) + XCTAssertEqual( + encodedFailure(encodedState( + localStorage: Dictionary( + uniqueKeysWithValues: (0 ... constrainedLimits.storageEntryCountLimit).map { ("k\($0)", "v") } + ), + sessionStorage: [:] + ))?.code, + .storageEntryLimitExceeded + ) + XCTAssertEqual( + encodedFailure(encodedState( + localStorage: [String(repeating: "k", count: constrainedLimits.storageKeyByteLimit + 1): "v"], + sessionStorage: [:] + ))?.code, + .storageKeyTooLarge + ) + XCTAssertEqual( + encodedFailure(encodedState( + localStorage: ["key": String(repeating: "v", count: constrainedLimits.storageValueByteLimit + 1)], + sessionStorage: [:] + ))?.code, + .storageValueTooLarge + ) + XCTAssertEqual( + encodedFailure(encodedState( + frameSelector: String(repeating: "f", count: constrainedLimits.frameSelectorByteLimit + 1) + ))?.code, + .frameSelectorTooLarge + ) + + let validData = try XCTUnwrap(try? encodedState().get()) + let tooSmallDocumentLimits = Limits( + documentByteLimit: validData.count - 1, + urlByteLimit: constrainedLimits.urlByteLimit, + cookieCountLimit: constrainedLimits.cookieCountLimit, + storageEntryCountLimit: constrainedLimits.storageEntryCountLimit, + storageKeyByteLimit: constrainedLimits.storageKeyByteLimit, + storageValueByteLimit: constrainedLimits.storageValueByteLimit, + frameSelectorByteLimit: constrainedLimits.frameSelectorByteLimit + ) + XCTAssertEqual( + encodedFailure(encodedState(limits: tooSmallDocumentLimits))?.code, + .documentTooLarge + ) + } + + func testStorageMutationCallbackKeepsSharedStoreLeasedAfterRestoreTimeout() throws { + let coordinator = TerminalController.V2BrowserStateRestoreLeaseCoordinator() + let store = WKWebsiteDataStore.nonPersistent() + let storeID = ObjectIdentifier(store) + let firstLease = try XCTUnwrap( + coordinator.acquire(dataStoreID: storeID, generation: UUID()) + ) + + XCTAssertTrue(coordinator.beginPendingMutation(firstLease), "Cookie writes are in flight") + XCTAssertTrue(coordinator.beginPendingMutation(firstLease), "The storage JavaScript callback is in flight") + coordinator.release(firstLease) + XCTAssertEqual( + coordinator.state(dataStoreID: storeID), + .taintedByUndrainedMutationCallbacks, + "If WebKit never invokes the callback, the store must remain explicitly tainted and busy rather than overlap a later restore" + ) + + XCTAssertTrue(coordinator.endPendingMutation(firstLease), "Cookie callbacks drained") + XCTAssertEqual(coordinator.state(dataStoreID: storeID), .taintedByUndrainedMutationCallbacks) + XCTAssertNil( + coordinator.acquire(dataStoreID: storeID, generation: UUID()), + "A timed-out restore must keep the store fenced until its late storage callback drains" + ) + + XCTAssertTrue(coordinator.endPendingMutation(firstLease), "The late storage callback drained") + XCTAssertEqual(coordinator.state(dataStoreID: storeID), .available) + let secondGeneration = UUID() + let secondLease = try XCTUnwrap( + coordinator.acquire(dataStoreID: storeID, generation: secondGeneration) + ) + XCTAssertFalse( + coordinator.endPendingMutation(firstLease), + "A duplicate storage callback must not release a newer restore lease" + ) + XCTAssertTrue(coordinator.isValid(secondLease, currentGeneration: secondGeneration)) + coordinator.release(secondLease) + } +} + +// MARK: - V2 Browser Download Event Invariants + +@MainActor +final class TerminalControllerV2BrowserDownloadEventTests: XCTestCase { + private func event(sequence: Int) -> [String: Any] { + ["sequence": sequence] + } + + private func sequence( + in event: [String: Any], + file: StaticString = #filePath, + line: UInt = #line + ) -> Int { + guard let value = event["sequence"] as? Int else { + XCTFail("Expected a download event sequence", file: file, line: line) + return -1 + } + return value + } + + func testDownloadQueueRetainsNewestBoundedEventsAndReportsEvictionOnce() { + let controller = TerminalController.shared + let surfaceId = UUID() + defer { controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + let limit = TerminalController.v2BrowserDownloadEventQueueLimit + XCTAssertEqual(limit, 256) + let overflowCount = 3 + for index in 0 ..< (limit + overflowCount) { + controller.v2BrowserEnqueueDownloadEvent( + surfaceId: surfaceId, + event: event(sequence: index) + ) + } + + var retainedSequences: [Int] = [] + var reportedDrops: [Int] = [] + for _ in 0 ..< limit { + guard let consumed = controller.v2BrowserConsumeDownloadEvent(surfaceId: surfaceId) else { + return XCTFail("The bounded queue must retain exactly its newest \(limit) events") + } + retainedSequences.append(sequence(in: consumed.event)) + reportedDrops.append(consumed.droppedEvents) + } + + XCTAssertEqual(retainedSequences, Array(overflowCount ..< (limit + overflowCount))) + XCTAssertEqual(reportedDrops.first, overflowCount) + XCTAssertTrue( + reportedDrops.dropFirst().allSatisfy { $0 == 0 }, + "Dropped-event metadata must be reported exactly once, not repeated for later downloads" + ) + XCTAssertNil(controller.v2BrowserConsumeDownloadEvent(surfaceId: surfaceId)) + } + + func testOneNotificationSatisfiesExactlyOneEventModeWait() { + let controller = TerminalController.shared + let surfaceId = UUID() + defer { controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: nil, + userInfo: [ + "surfaceId": surfaceId, + "event": ["sequence": 42], + ] + ) + } + + switch controller.v2BrowserWaitForDownloadEvent(surfaceId: surfaceId, timeout: 1.0) { + case .event(let download, let droppedEvents): + XCTAssertEqual(sequence(in: download), 42) + XCTAssertEqual(droppedEvents, 0) + case .timedOut: + XCTFail("The posted download notification must satisfy the active waiter") + case .cancelled: + XCTFail("A live surface waiter must not be cancelled") + case .busy: + XCTFail("The only active event-mode wait must not report busy") + } + + switch controller.v2BrowserWaitForDownloadEvent(surfaceId: surfaceId, timeout: 0.01) { + case .timedOut: + break + case .event(let duplicate, _): + XCTFail("One notification must not be returned by two waits: \(duplicate)") + case .cancelled: + XCTFail("A live surface waiter must time out rather than report cancellation") + case .busy: + XCTFail("The previous wait completed, so a later wait must not report busy") + } + } + + func testNestedWaitReturnsBusyWithoutStealingTheOuterWaitEvent() { + let controller = TerminalController.shared + let outerSurfaceId = UUID() + let nestedSurfaceId = UUID() + defer { + controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: outerSurfaceId) + controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: nestedSurfaceId) + } + + DispatchQueue.main.async { + switch controller.v2BrowserWaitForDownloadEvent( + surfaceId: nestedSurfaceId, + timeout: 0.1 + ) { + case .busy: + break + case .event(let event, _): + XCTFail("A nested wait must not consume an unrelated event: \(event)") + case .timedOut: + XCTFail("A nested synchronous wait must fail busy instead of starting another run loop") + case .cancelled: + XCTFail("The live nested surface must report busy rather than cancellation") + } + + controller.v2BrowserEnqueueDownloadEvent( + surfaceId: outerSurfaceId, + event: ["sequence": 77] + ) + } + + switch controller.v2BrowserWaitForDownloadEvent(surfaceId: outerSurfaceId, timeout: 1.0) { + case .event(let download, let droppedEvents): + XCTAssertEqual(sequence(in: download), 77) + XCTAssertEqual(droppedEvents, 0) + case .timedOut: + XCTFail("Rejecting the nested wait must leave the original waiter active") + case .cancelled: + XCTFail("Rejecting a nested wait must not cancel the original waiter") + case .busy: + XCTFail("The original wait establishes the active wait and must not report busy") + } + + XCTAssertNil( + controller.v2BrowserConsumeDownloadEvent(surfaceId: outerSurfaceId), + "The event delivered to the original waiter must not also remain queued" + ) + } + + func testPermanentSurfaceCleanupClearsQueuedEventsAndOverflowMetadata() { + let controller = TerminalController.shared + let surfaceId = UUID() + defer { controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + for index in 0 ... TerminalController.v2BrowserDownloadEventQueueLimit { + controller.v2BrowserEnqueueDownloadEvent( + surfaceId: surfaceId, + event: event(sequence: index) + ) + } + + controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) + XCTAssertNil( + controller.v2BrowserConsumeDownloadEvent(surfaceId: surfaceId), + "Closing a surface must make every queued download unreachable" + ) + + controller.v2BrowserEnqueueDownloadEvent( + surfaceId: surfaceId, + event: event(sequence: 999) + ) + guard let fresh = controller.v2BrowserConsumeDownloadEvent(surfaceId: surfaceId) else { + return XCTFail("A reused surface identifier must accept new download events after cleanup") + } + XCTAssertEqual(sequence(in: fresh.event), 999) + XCTAssertEqual( + fresh.droppedEvents, + 0, + "Overflow metadata from the removed surface lifetime must not leak into a new lifetime" + ) + } + + func testPermanentSurfaceCleanupCancelsAnActiveWaiter() { + let controller = TerminalController.shared + let surfaceId = UUID() + defer { controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + DispatchQueue.main.async { + controller.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) + } + + switch controller.v2BrowserWaitForDownloadEvent(surfaceId: surfaceId, timeout: 1.0) { + case .cancelled: + break + case .event(let event, _): + XCTFail("Removing the surface must not fabricate a download event: \(event)") + case .timedOut: + XCTFail("Surface cleanup must cancel an active waiter immediately") + case .busy: + XCTFail("The only active event-mode wait must not report busy") + } + } + +} + +// MARK: - V2 Browser Automation Ref Invariants (audit M6a / M8) + +@MainActor +final class TerminalControllerV2RefInvariantTests: XCTestCase { + /// Test-facing allocator contract: returned tokens align positionally with `selectors`. + /// Existing selectors deduplicate; unseen selectors are committed atomically or the whole + /// request returns `resourceExhausted` without changing per-surface state. + private func allocateElementRefs( + surfaceId: UUID, + selectors: [String], + file: StaticString = #filePath, + line: UInt = #line + ) -> [String] { + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: selectors + ) { + case .allocated(let refs): + XCTAssertEqual(refs.count, selectors.count, file: file, line: line) + return refs + case .resourceExhausted(let capacity): + XCTFail( + "Unexpected element-ref exhaustion: limit=\(capacity.limit) requested=\(capacity.requestedUnique) remaining=\(capacity.remaining) bytes=\(capacity.requestedBytes)/\(capacity.remainingBytes)", + file: file, + line: line + ) + return [] + } + } + + private func allocateElementRef( + surfaceId: UUID, + selector: String, + file: StaticString = #filePath, + line: UInt = #line + ) -> String { + let refs = allocateElementRefs( + surfaceId: surfaceId, + selectors: [selector], + file: file, + line: line + ) + guard let ref = refs.first else { + XCTFail("Expected one allocated element ref", file: file, line: line) + return "" + } + return ref + } + + private func elementRefOrdinal(_ ref: String) -> Int? { + guard ref.hasPrefix("@e") else { return nil } + return Int(ref.dropFirst(2)) + } + + private func selector(byteCount: Int, suffix: String) -> String { + let suffix = "-\(suffix)" + precondition(suffix.utf8.count <= byteCount) + return String(repeating: "x", count: byteCount - suffix.utf8.count) + suffix + } + + /// M6a: an element ref (@eN) allocated before a navigation must not silently re-resolve + /// against the new page's DOM after the surface navigates — it should report a structured + /// `stale_element` error instead. + func testElementRefResolvesUntilSurfaceNavigatesThenReportsStale() { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + let ref = allocateElementRef(surfaceId: surfaceId, selector: "#foo") + + // Before any navigation, the ref resolves normally. + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: surfaceId), "#foo") + + // Simulate a committed main-frame navigation on that surface (BrowserPanel's + // navigationDelegate.didCommit calls this in production). + TerminalController.shared.v2BrowserBumpNavigationGeneration(forSurface: surfaceId) + + // The pre-navigation ref must no longer resolve... + XCTAssertNil(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: surfaceId)) + + // ...and the error surfaced must specifically be stale_element, not a generic not_found, + // so callers can distinguish "this ref is dead" from "this ref never existed". + switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: surfaceId) { + case .err(let code, _, let data): + XCTAssertEqual(code, "stale_element") + XCTAssertEqual(data as? [String: String], ["ref": ref]) + case .ok: + XCTFail("expected an error result for a stale ref") + } + + // A ref allocated *after* the navigation on the same surface resolves normally again. + let freshRef = allocateElementRef(surfaceId: surfaceId, selector: "#bar") + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(freshRef, surfaceId: surfaceId), "#bar") } /// M6a: a ref allocated on one surface must never resolve against a different surface, with @@ -5267,7 +5522,11 @@ final class TerminalControllerV2RefInvariantTests: XCTestCase { func testElementRefDoesNotResolveAgainstAnotherSurface() { let surfaceA = UUID() let surfaceB = UUID() - let ref = TerminalController.shared.v2BrowserAllocateElementRef(surfaceId: surfaceA, selector: "#foo") + defer { + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceA) + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceB) + } + let ref = allocateElementRef(surfaceId: surfaceA, selector: "#foo") XCTAssertNil(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: surfaceB)) switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: surfaceB) { @@ -5278,6 +5537,582 @@ final class TerminalControllerV2RefInvariantTests: XCTestCase { } } + func testElementRefsDeduplicateWithinSurfaceGenerationButNotAcrossSurfaces() { + let surfaceA = UUID() + let surfaceB = UUID() + defer { + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceA) + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceB) + } + + let first = allocateElementRef(surfaceId: surfaceA, selector: "#same") + let duplicate = allocateElementRef(surfaceId: surfaceA, selector: "#same") + let otherSurface = allocateElementRef(surfaceId: surfaceB, selector: "#same") + + XCTAssertEqual(duplicate, first) + XCTAssertNotEqual(otherSurface, first) + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(first, surfaceId: surfaceA), "#same") + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(otherSurface, surfaceId: surfaceB), "#same") + } + + func testElementRefQuotaRejectsOnlyTheFirstUnseenSelectorBeyondTheLimit() { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + let limit = TerminalController.v2BrowserElementRefLimit + XCTAssertEqual(limit, 4096) + let selectors = (0 ..< limit).map { "#quota-\($0)" } + let refs = allocateElementRefs(surfaceId: surfaceId, selectors: selectors) + XCTAssertEqual(Set(refs).count, limit) + + let duplicate = allocateElementRef(surfaceId: surfaceId, selector: selectors[0]) + XCTAssertEqual(duplicate, refs[0], "A duplicate must not consume another quota slot") + + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: ["#quota-overflow"] + ) { + case .allocated: + XCTFail("Expected the first unseen selector beyond the per-generation limit to fail") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.limit, limit) + XCTAssertEqual(capacity.requestedUnique, 1) + XCTAssertEqual(capacity.remaining, 0) + } + + XCTAssertTrue(zip(refs, selectors).allSatisfy { pair in + TerminalController.shared.v2BrowserResolveSelector(pair.0, surfaceId: surfaceId) == pair.1 + }, "Resource exhaustion must not invalidate any previously allocated ref") + } + + func testNavigationRetainsOnlyOneStaleGenerationAndResetsQuotaWithoutReusingOrdinals() throws { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + let limit = TerminalController.v2BrowserElementRefLimit + let generationNSelectors = (0 ..< limit).map { "#generation-n-\($0)" } + let generationNRefs = allocateElementRefs(surfaceId: surfaceId, selectors: generationNSelectors) + let generationNFirstOrdinal = elementRefOrdinal(generationNRefs[0]) + let generationNLastOrdinal = elementRefOrdinal(generationNRefs[limit - 1]) + + TerminalController.shared.v2BrowserBumpNavigationGeneration(forSurface: surfaceId) + XCTAssertNil(TerminalController.shared.v2BrowserResolveSelector(generationNRefs[0], surfaceId: surfaceId)) + switch TerminalController.shared.v2BrowserSelectorResolutionError(generationNRefs[0], surfaceId: surfaceId) { + case .err(let code, _, _): + XCTAssertEqual(code, "stale_element") + case .ok: + XCTFail("Expected generation N to be retained as stale") + } + + let generationNPlusOneSelectors = (0 ..< limit).map { "#generation-n-plus-one-\($0)" } + let generationNPlusOneRefs = allocateElementRefs( + surfaceId: surfaceId, + selectors: generationNPlusOneSelectors + ) + let generationNPlusOneRef = generationNPlusOneRefs[0] + XCTAssertNotEqual(generationNPlusOneRef, generationNRefs[0]) + let generationNPlusOneOrdinal = try XCTUnwrap(elementRefOrdinal(generationNPlusOneRef)) + let unwrappedGenerationNLastOrdinal = try XCTUnwrap(generationNLastOrdinal) + XCTAssertGreaterThan(generationNPlusOneOrdinal, unwrappedGenerationNLastOrdinal) + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: ["#generation-n-plus-one-overflow"] + ) { + case .allocated: + XCTFail("A navigation must reset exactly one full generation of quota, not remove the limit") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.requestedUnique, 1) + XCTAssertEqual(capacity.remaining, 0) + } + + TerminalController.shared.v2BrowserBumpNavigationGeneration(forSurface: surfaceId) + switch TerminalController.shared.v2BrowserSelectorResolutionError(generationNRefs[0], surfaceId: surfaceId) { + case .err(let code, _, _): + XCTAssertEqual(code, "not_found", "Generation N must be discarded after the next navigation") + case .ok: + XCTFail("Expected generation N to be removed") + } + switch TerminalController.shared.v2BrowserSelectorResolutionError(generationNPlusOneRef, surfaceId: surfaceId) { + case .err(let code, _, _): + XCTAssertEqual(code, "stale_element", "Generation N+1 must remain available as stale") + case .ok: + XCTFail("Expected generation N+1 to be stale") + } + + let generationNPlusTwoRef = allocateElementRef(surfaceId: surfaceId, selector: "#generation-n-plus-two") + let generationNPlusTwoOrdinal = try XCTUnwrap(elementRefOrdinal(generationNPlusTwoRef)) + let unwrappedGenerationNFirstOrdinal = try XCTUnwrap(generationNFirstOrdinal) + XCTAssertGreaterThan(generationNPlusTwoOrdinal, generationNPlusOneOrdinal) + XCTAssertLessThan(unwrappedGenerationNFirstOrdinal, generationNPlusOneOrdinal) + } + + func testBatchAllocationIsAtomicAndDuplicatesDoNotConsumeTheLastSlot() { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + let limit = TerminalController.v2BrowserElementRefLimit + let existingSelectors = (0 ..< (limit - 1)).map { "#atomic-existing-\($0)" } + let existingRefs = allocateElementRefs(surfaceId: surfaceId, selectors: existingSelectors) + + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: ["#atomic-new-a", "#atomic-new-b"] + ) { + case .allocated: + XCTFail("Two unseen selectors must not partially consume the final slot") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.limit, limit) + XCTAssertEqual(capacity.requestedUnique, 2) + XCTAssertEqual(capacity.remaining, 1) + } + + let finalRefs = allocateElementRefs( + surfaceId: surfaceId, + selectors: [existingSelectors[0], "#atomic-new-a", existingSelectors[0]] + ) + XCTAssertEqual(finalRefs[0], existingRefs[0]) + XCTAssertEqual(finalRefs[2], existingRefs[0]) + XCTAssertEqual( + TerminalController.shared.v2BrowserResolveSelector(finalRefs[1], surfaceId: surfaceId), + "#atomic-new-a", + "The failed batch must not have allocated either unseen selector" + ) + + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: ["#atomic-new-b"] + ) { + case .allocated: + XCTFail("The one successful unseen selector must consume the final slot") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.limit, limit) + XCTAssertEqual(capacity.requestedUnique, 1) + XCTAssertEqual(capacity.remaining, 0) + } + } + + func testPermanentSurfaceCleanupRemovesCurrentStaleIndexedAndQuotaState() throws { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + let limit = TerminalController.v2BrowserElementRefLimit + let generationNSelectors = (0 ..< limit).map { "#cleanup-generation-n-\($0)" } + let generationNRefs = allocateElementRefs(surfaceId: surfaceId, selectors: generationNSelectors) + TerminalController.shared.v2BrowserBumpNavigationGeneration(forSurface: surfaceId) + let currentSelectors = (0 ..< limit).map { "#cleanup-current-\($0)" } + let currentRefs = allocateElementRefs(surfaceId: surfaceId, selectors: currentSelectors) + let currentRef = currentRefs[0] + + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) + + XCTAssertEqual(TerminalController.shared.v2BrowserNavigationGeneration(forSurface: surfaceId), 0) + for removedRef in [generationNRefs[0], currentRef] { + switch TerminalController.shared.v2BrowserSelectorResolutionError(removedRef, surfaceId: surfaceId) { + case .err(let code, _, _): + XCTAssertEqual(code, "not_found") + case .ok: + XCTFail("Permanent cleanup must remove current and retained-stale refs") + } + } + + let replacementRefs = allocateElementRefs(surfaceId: surfaceId, selectors: currentSelectors) + XCTAssertNotEqual(replacementRefs[0], generationNRefs[0]) + XCTAssertNotEqual(replacementRefs[0], currentRef) + XCTAssertEqual( + TerminalController.shared.v2BrowserResolveSelector(replacementRefs[0], surfaceId: surfaceId), + currentSelectors[0] + ) + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: ["#cleanup-overflow"] + ) { + case .allocated: + XCTFail("Permanent cleanup must reset exactly one full quota, not disable enforcement") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.requestedUnique, 1) + XCTAssertEqual(capacity.remaining, 0) + } + XCTAssertGreaterThan( + try XCTUnwrap(elementRefOrdinal(replacementRefs[0])), + try XCTUnwrap(elementRefOrdinal(currentRefs.last!)), + "Permanent cleanup must not rewind the global ref ordinal" + ) + } + + func testElementRefQuotaIsIndependentAcrossSurfacesAtCountAndByteCapacity() { + let surfaceA = UUID() + let surfaceB = UUID() + defer { + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceA) + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceB) + } + + XCTAssertEqual(TerminalController.v2BrowserElementRefLimit, 4_096) + XCTAssertEqual(TerminalController.v2BrowserElementRefSelectorByteLimit, 16_384) + XCTAssertEqual(TerminalController.v2BrowserElementRefByteLimit, 4_194_304) + let selectors = (0 ..< 4_096).map { selector(byteCount: 1_024, suffix: "pressure-\($0)") } + let refsA = allocateElementRefs(surfaceId: surfaceA, selectors: selectors) + let refsB = allocateElementRefs(surfaceId: surfaceB, selectors: selectors) + + XCTAssertEqual(Set(refsA).count, 4_096) + XCTAssertEqual(Set(refsB).count, 4_096) + XCTAssertTrue(Set(refsA).isDisjoint(with: Set(refsB))) + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(refsB.last!, surfaceId: surfaceB), selectors.last!) + } + + func testElementRefByteLimitsRejectOversizeAndAggregateOverflowAtomically() throws { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + + let selectorByteLimit = TerminalController.v2BrowserElementRefSelectorByteLimit + let byteLimit = TerminalController.v2BrowserElementRefByteLimit + XCTAssertEqual(selectorByteLimit, 16_384) + XCTAssertEqual(byteLimit, 4_194_304) + + let oversized = selector(byteCount: selectorByteLimit + 1, suffix: "oversized") + switch TerminalController.shared.v2BrowserAllocateElementRefs(surfaceId: surfaceId, selectors: [oversized]) { + case .allocated: + XCTFail("One selector must not exceed the per-selector UTF-8 byte limit") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.selectorByteLimit, selectorByteLimit) + XCTAssertEqual(capacity.requestedBytes, oversized.utf8.count) + XCTAssertEqual(capacity.remainingBytes, byteLimit) + } + + let existingSelectors = (0 ..< 255).map { selector(byteCount: selectorByteLimit, suffix: "existing-\($0)") } + let existingRefs = allocateElementRefs(surfaceId: surfaceId, selectors: existingSelectors) + let unseenA = selector(byteCount: 8_193, suffix: "unseen-a") + let unseenB = selector(byteCount: 8_193, suffix: "unseen-b") + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: surfaceId, + selectors: [existingSelectors[0], unseenA, unseenB, existingSelectors[0]] + ) { + case .allocated: + XCTFail("A batch whose new selectors exceed remaining bytes must fail atomically") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.limit, 4_096) + XCTAssertEqual(capacity.requestedUnique, 2) + XCTAssertEqual(capacity.remaining, 4_096 - existingSelectors.count) + XCTAssertEqual(capacity.selectorByteLimit, selectorByteLimit) + XCTAssertEqual(capacity.byteLimit, byteLimit) + XCTAssertEqual(capacity.requestedBytes, unseenA.utf8.count + unseenB.utf8.count) + XCTAssertEqual(capacity.remainingBytes, selectorByteLimit) + + switch TerminalController.shared.v2BrowserElementRefResourceExhaustedResult( + surfaceId: surfaceId, + capacity: capacity + ) { + case .ok: + XCTFail("Capacity exhaustion must produce an error result") + case .err(let code, let message, let data): + XCTAssertEqual(code, "resource_exhausted") + XCTAssertEqual(message, "Browser element reference limit reached for this page") + XCTAssertEqual(data as? [String: AnyHashable], [ + "surface_id": surfaceId.uuidString, + "limit": capacity.limit, + "scope": "navigation", + "retry": "navigate or reuse an existing selector", + "requested_unique": capacity.requestedUnique, + "remaining": capacity.remaining, + "selector_byte_limit": capacity.selectorByteLimit, + "byte_limit": capacity.byteLimit, + "requested_bytes": capacity.requestedBytes, + "remaining_bytes": capacity.remainingBytes + ]) + } + } + + XCTAssertTrue(zip(existingRefs, existingSelectors).allSatisfy { pair in + TerminalController.shared.v2BrowserResolveSelector(pair.0, surfaceId: surfaceId) == pair.1 + }, "An aggregate-capacity rejection must leave every prior ref resolvable") + XCTAssertNil(TerminalController.shared.v2BrowserResolveSelector("@never-allocated", surfaceId: surfaceId)) + let committedA = allocateElementRef(surfaceId: surfaceId, selector: unseenA) + XCTAssertEqual( + try XCTUnwrap(elementRefOrdinal(committedA)), + try XCTUnwrap(elementRefOrdinal(existingRefs.last!)) + 1, + "The rejected batch must not consume ordinals or pre-allocate either unseen selector" + ) + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(committedA, surfaceId: surfaceId), unseenA) + XCTAssertEqual(allocateElementRef(surfaceId: surfaceId, selector: unseenA), committedA, "An exact duplicate consumes no additional bytes") + switch TerminalController.shared.v2BrowserAllocateElementRefs(surfaceId: surfaceId, selectors: [unseenB]) { + case .allocated: + XCTFail("The failed aggregate batch must not pre-allocate its second unseen selector") + case .resourceExhausted(let capacity): + XCTAssertEqual(capacity.requestedUnique, 1) + XCTAssertEqual(capacity.requestedBytes, unseenB.utf8.count) + XCTAssertEqual(capacity.remainingBytes, selectorByteLimit - unseenA.utf8.count) + } + } + + func testNavigationAndPermanentCleanupResetElementRefByteBudget() throws { + let surfaceId = UUID() + defer { TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) } + let selectors = (0 ..< 256).map { selector(byteCount: 16_384, suffix: "byte-reset-\($0)") } + + let generationN = allocateElementRefs(surfaceId: surfaceId, selectors: selectors) + TerminalController.shared.v2BrowserBumpNavigationGeneration(forSurface: surfaceId) + let generationNPlusOne = allocateElementRefs(surfaceId: surfaceId, selectors: selectors) + XCTAssertNotEqual(generationN[0], generationNPlusOne[0]) + + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: surfaceId) + let afterCleanup = allocateElementRefs(surfaceId: surfaceId, selectors: selectors) + XCTAssertNotEqual(generationNPlusOne[0], afterCleanup[0]) + XCTAssertGreaterThan( + try XCTUnwrap(elementRefOrdinal(afterCleanup[0])), + try XCTUnwrap(elementRefOrdinal(generationNPlusOne.last!)) + ) + } + + func testSnapshotPostProcessingBoundsRealResponseContentAndReportsEveryTruncationCause() { + XCTAssertEqual(TerminalController.v2BrowserSnapshotNodeVisitLimit, 4_096) + XCTAssertEqual(TerminalController.v2BrowserSnapshotEntryLimit, 256) + XCTAssertEqual(TerminalController.v2BrowserSnapshotTextCharacterLimit, 262_144) + XCTAssertEqual(TerminalController.v2BrowserSnapshotHTMLCharacterLimit, 1_048_576) + var entries: [[String: Any]] = (0 ..< 300).map { index in + ["selector": "#snapshot-\(index)", "role": "button"] + } + entries.insert(["selector": "#snapshot-0", "role": "duplicate"], at: 1) + + let fullText = String(repeating: "t", count: TerminalController.v2BrowserSnapshotTextCharacterLimit + 1) + let fullHTML = String(repeating: "h", count: TerminalController.v2BrowserSnapshotHTMLCharacterLimit + 1) + let swiftTruncated = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", + "url": "https://example.com", + "entries": entries, + "text": fullText, + "html": fullHTML, + "visited_nodes": 301 + ]) + + XCTAssertEqual(swiftTruncated.entries.count, 256) + XCTAssertEqual(swiftTruncated.entries.first?["selector"] as? String, "#snapshot-0") + XCTAssertEqual(swiftTruncated.entries.last?["selector"] as? String, "#snapshot-255") + XCTAssertEqual(Set(swiftTruncated.entries.compactMap { $0["selector"] as? String }).count, 256) + XCTAssertEqual(swiftTruncated.text, String(fullText.prefix(TerminalController.v2BrowserSnapshotTextCharacterLimit))) + XCTAssertEqual(swiftTruncated.html, String(fullHTML.prefix(TerminalController.v2BrowserSnapshotHTMLCharacterLimit))) + XCTAssertEqual(swiftTruncated.text.count, TerminalController.v2BrowserSnapshotTextCharacterLimit) + XCTAssertEqual(swiftTruncated.html.count, TerminalController.v2BrowserSnapshotHTMLCharacterLimit) + XCTAssertEqual(swiftTruncated.metadata["truncated"] as? Bool, true) + XCTAssertEqual(swiftTruncated.metadata["element_limit"] as? Int, 256) + XCTAssertEqual(swiftTruncated.metadata["text_truncated"] as? Bool, true) + XCTAssertEqual(swiftTruncated.metadata["html_truncated"] as? Bool, true) + + let browserTruncated = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", + "url": "https://example.com", + "entries": [["selector": "#one", "role": "button"]], + "text": "short", + "html": "

short

", + "truncated": true, + "truncation_reasons": ["node_limit"], + "node_limit": 4_096, + "visited_nodes": 4_096, + "text_truncated": true, + "html_truncated": true + ]) + XCTAssertEqual(browserTruncated.metadata["truncated"] as? Bool, true) + XCTAssertEqual(browserTruncated.metadata["element_limit"] as? Int, 256) + XCTAssertEqual(browserTruncated.metadata["text_truncated"] as? Bool, true) + XCTAssertEqual(browserTruncated.metadata["html_truncated"] as? Bool, true) + } + + func testSnapshotPostProcessingMarksAggregateTruncatedWhenOnlyPageTextOrHTMLIsClipped() { + let oversizedText = String( + repeating: "t", + count: TerminalController.v2BrowserSnapshotTextCharacterLimit + 1 + ) + let textOnly = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", + "url": "about:blank", + "entries": [], + "text": oversizedText, + "html": "

short

" + ]) + XCTAssertEqual(textOnly.metadata["text_truncated"] as? Bool, true) + XCTAssertEqual(textOnly.metadata["truncated"] as? Bool, true) + + let oversizedHTML = String( + repeating: "h", + count: TerminalController.v2BrowserSnapshotHTMLCharacterLimit + 1 + ) + let htmlOnly = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", + "url": "about:blank", + "entries": [], + "text": "short", + "html": oversizedHTML + ]) + XCTAssertEqual(htmlOnly.metadata["html_truncated"] as? Bool, true) + XCTAssertEqual(htmlOnly.metadata["truncated"] as? Bool, true) + } + + func testSnapshotPostProcessingRevalidatesUntrustedEntryAndMetadataBounds() { + let oversizedSelector = "#" + String(repeating: "s", count: 16_384) + let longName = String(repeating: "é", count: 600) + let longRole = String(repeating: "r", count: 65) + let longTitle = String(repeating: "T", count: 1_025) + let longURL = "https://example.com/" + String(repeating: "u", count: 17_000) + let result = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": longTitle, + "url": longURL, + "text": "text", + "html": "

html

", + "entries": [ + ["selector": "#first", "name": "first", "role": "button"], + ["selector": "#first", "name": "duplicate", "role": "button"], + ["selector": "", "name": "empty", "role": "button"], + ["selector": oversizedSelector, "name": "oversized", "role": "button"], + ["selector": "#long-name", "name": longName, "role": "button"], + ["selector": "#long-role", "name": "role", "role": longRole] + ], + "truncated": true, + "truncation_reasons": ["url_byte_limit", "unknown", "node_limit"], + "node_limit": 4_096, + "visited_nodes": 99_999, + "entry_bytes": Int.max, + "selector_skipped_count": 999, + "name_truncated_count": 999, + "role_skipped_count": 999 + ]) + + XCTAssertEqual(result.title, String(longTitle.prefix(1_024))) + XCTAssertEqual(result.url, String(longURL.prefix(16_384))) + XCTAssertEqual(result.entries.count, 2) + XCTAssertEqual(result.entries[0]["selector"] as? String, "#first") + XCTAssertEqual(result.entries[1]["selector"] as? String, "#long-name") + XCTAssertEqual(result.entries[1]["name"] as? String, String(repeating: "é", count: 512)) + XCTAssertEqual(((result.entries[1]["name"] as? String) ?? "").utf8.count, 1_024) + XCTAssertEqual(result.metadata["truncation_reasons"] as? [String], [ + "node_limit", + "selector_byte_limit", + "name_byte_limit", + "role_byte_limit", + "title_byte_limit", + "url_byte_limit" + ]) + XCTAssertEqual(result.metadata["node_limit"] as? Int, 4_096) + XCTAssertEqual(result.metadata["visited_nodes"] as? Int, 4_096) + XCTAssertEqual(result.metadata["selector_byte_limit"] as? Int, 16_384) + XCTAssertEqual(result.metadata["selector_skipped_count"] as? Int, 1) + XCTAssertEqual(result.metadata["name_byte_limit"] as? Int, 1_024) + XCTAssertEqual(result.metadata["name_truncated_count"] as? Int, 1) + XCTAssertEqual(result.metadata["role_byte_limit"] as? Int, 64) + XCTAssertEqual(result.metadata["role_skipped_count"] as? Int, 1) + XCTAssertEqual(result.metadata["title_byte_limit"] as? Int, 1_024) + XCTAssertEqual(result.metadata["url_byte_limit"] as? Int, 16_384) + let acceptedBytes = result.entries.reduce(into: 0) { total, entry in + total += ((entry["selector"] as? String) ?? "").utf8.count + total += ((entry["name"] as? String) ?? "").utf8.count + total += ((entry["role"] as? String) ?? "").utf8.count + } + XCTAssertEqual(result.metadata["entry_byte_limit"] as? Int, 262_144) + XCTAssertEqual(result.metadata["entry_bytes"] as? Int, acceptedBytes) + XCTAssertEqual(result.metadata["truncated"] as? Bool, true) + } + + func testSnapshotPostProcessingCountAndAggregateByteLimitsKeepAtomicPrefixes() { + let countEntries: [[String: Any]] = (0 ..< 300).map { + ["selector": "#count-\($0)", "name": "n", "role": "button"] + } + let countBounded = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", "url": "about:blank", "text": "", "html": "", "entries": countEntries + ]) + XCTAssertEqual(countBounded.entries.count, 256) + XCTAssertEqual(countBounded.entries.last?["selector"] as? String, "#count-255") + XCTAssertEqual(countBounded.metadata["truncation_reasons"] as? [String], ["entry_limit"]) + XCTAssertEqual(countBounded.metadata["element_limit"] as? Int, 256) + + let byteEntries: [[String: Any]] = (0 ..< 300).map { + ["selector": "#bytes-\($0)", "name": String(repeating: "n", count: 1_024), "role": "button"] + } + let byteBounded = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", "url": "about:blank", "text": "", "html": "", "entries": byteEntries + ]) + let usedBytes = byteBounded.entries.reduce(into: 0) { total, entry in + total += ((entry["selector"] as? String) ?? "").utf8.count + total += ((entry["name"] as? String) ?? "").utf8.count + total += ((entry["role"] as? String) ?? "").utf8.count + } + XCTAssertLessThan(byteBounded.entries.count, 256) + XCTAssertEqual(byteBounded.entries.compactMap { $0["selector"] as? String }, (0 ..< byteBounded.entries.count).map { "#bytes-\($0)" }) + XCTAssertEqual(byteBounded.metadata["entry_bytes"] as? Int, usedBytes) + XCTAssertLessThanOrEqual(usedBytes, 262_144) + XCTAssertEqual(byteBounded.metadata["truncation_reasons"] as? [String], ["entry_byte_limit"]) + } + + func testSnapshotPostProcessingClampsDepthAndDropsUnknownEntryPayload() { + let result = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", + "url": "about:blank", + "text": "text", + "html": "

html

", + "entries": [[ + "selector": "#bounded-entry", + "name": "Bounded entry", + "role": "button", + "depth": Int.max, + "unknown_payload": String(repeating: "x", count: 1_000_000) + ]] + ]) + + XCTAssertEqual(result.entries.count, 1) + guard let entry = result.entries.first else { + return XCTFail("Expected the valid bounded entry to survive post-processing") + } + XCTAssertEqual(Set(entry.keys), Set(["selector", "name", "role", "depth"])) + XCTAssertEqual(entry["selector"] as? String, "#bounded-entry") + XCTAssertEqual(entry["name"] as? String, "Bounded entry") + XCTAssertEqual(entry["role"] as? String, "button") + XCTAssertEqual(entry["depth"] as? Int, TerminalController.v2BrowserSnapshotMaxDepth) + XCTAssertNil(entry["unknown_payload"], "Post-processing must not retain unbounded unknown entry data") + } + + func testSnapshotPostProcessingInspectsOnlyTheBoundedRawEntryPrefix() { + let rawLimit = TerminalController.v2BrowserSnapshotRawEntryLimit + XCTAssertEqual(rawLimit, 4_096) + let oversizedRole = String(repeating: "R", count: TerminalController.v2BrowserSnapshotRoleByteLimit + 1) + XCTAssertGreaterThan(oversizedRole.utf8.count, TerminalController.v2BrowserSnapshotRoleByteLimit) + + var rawEntries: [[String: Any]] = [[ + "selector": "#accepted-prefix", + "name": "Accepted prefix", + "role": "button", + "depth": 0 + ], [ + "selector": "#oversized-role", + "name": "Must be rejected before normalization", + "role": oversizedRole, + "depth": 0 + ]] + while rawEntries.count < rawLimit { + rawEntries.append( + rawEntries.count.isMultiple(of: 2) + ? ["selector": "#accepted-prefix", "name": "duplicate", "role": "button"] + : ["selector": "", "name": "invalid", "role": "button"] + ) + } + rawEntries.append([ + "selector": "#valid-after-inspection-budget", + "name": "Tail must not be inspected", + "role": "button", + "depth": 0 + ]) + + let result = TerminalController.shared.v2BrowserPostProcessSnapshotResult([ + "title": "title", + "url": "about:blank", + "text": "", + "html": "", + "entries": rawEntries + ]) + + XCTAssertEqual(result.entries.count, 1) + XCTAssertEqual(result.entries.first?["selector"] as? String, "#accepted-prefix") + XCTAssertFalse(result.entries.contains { $0["selector"] as? String == "#valid-after-inspection-budget" }) + XCTAssertEqual(result.metadata["truncated"] as? Bool, true) + XCTAssertEqual(result.metadata["raw_entry_limit"] as? Int, rawLimit) + } + /// M8: pruning dead handle-ref map entries must never let a ref string get reissued for a /// different UUID. The per-kind ordinal counter is untouched by pruning — a /// pruned-then-reappearing UUID gets a brand-new ref, not its old one back, and no other diff --git a/programaTests/TerminalControllerSocketSecurityTests.swift b/programaTests/TerminalControllerSocketSecurityTests.swift index 81f04666..e8af5f49 100644 --- a/programaTests/TerminalControllerSocketSecurityTests.swift +++ b/programaTests/TerminalControllerSocketSecurityTests.swift @@ -2,6 +2,7 @@ import XCTest import AppKit import Combine import Darwin +import os #if canImport(Programa_DEV) @testable import Programa_DEV @@ -9,6 +10,127 @@ import Darwin @testable import Programa #endif +private final class TestSocketPasswordCredentialHolder: Sendable { + private let password: OSAllocatedUnfairLock + + init(password: String) { + self.password = OSAllocatedUnfairLock(initialState: password) + } + + func update(password: String) { + self.password.withLock { storedPassword in + storedPassword = password + } + } + + var source: TerminalController.SocketPasswordCredentialSource { + TerminalController.SocketPasswordCredentialSource( + hasConfiguredPassword: { [self] in + password.withLock { !$0.isEmpty } + }, + verify: { [self] candidate in + password.withLock { $0 == candidate } + } + ) + } +} + +private struct MainQueueBlockedTelemetryObservation: Sendable { + var responseReceived = false + var responseOK = false + var responseWorkspaceID: String? + var responseKey: String? + var responseValue: String? + var mainQueueWasBlockedAtResponse = false + var statusPublicationCountBeforeRelease: Int? + var errorDescription: String? +} + +private struct MainQueueBlockedQueryObservation: Sendable { + var confirmedNoResponseWhileBlocked = false + var responseArrivedWhileBlocked = false + var responseOK = false + var windowCount: Int? + var errorDescription: String? +} + +private struct MainQueueBlockedInvalidTelemetryObservation: Sendable { + var reportTTYResponseOK: Bool? + var reportTTYErrorCode: String? + var reportTTYErrorMessage: String? + var reportTTYResponseWhileBlocked = false + var portsKickResponseOK: Bool? + var portsKickErrorCode: String? + var portsKickErrorMessage: String? + var portsKickResponseWhileBlocked = false + var reportPWDResponseOK: Bool? + var reportPWDErrorCode: String? + var reportPWDErrorMessage: String? + var reportPWDResponseWhileBlocked = false + var reportShellStateResponseOK: Bool? + var reportShellStateErrorCode: String? + var reportShellStateErrorMessage: String? + var reportShellStateResponseWhileBlocked = false + var reportAgentStateResponseOK: Bool? + var reportAgentStateErrorCode: String? + var reportAgentStateErrorMessage: String? + var reportAgentStateResponseWhileBlocked = false + var clearAgentStateResponseOK: Bool? + var clearAgentStateErrorCode: String? + var clearAgentStateErrorMessage: String? + var clearAgentStateResponseWhileBlocked = false + var setAgentPIDResponseOK: Bool? + var setAgentPIDErrorCode: String? + var setAgentPIDErrorMessage: String? + var setAgentPIDResponseWhileBlocked = false + var clearAgentPIDResponseOK: Bool? + var clearAgentPIDErrorCode: String? + var clearAgentPIDErrorMessage: String? + var clearAgentPIDResponseWhileBlocked = false + var setStatusResponseOK: Bool? + var setStatusErrorCode: String? + var setStatusErrorMessage: String? + var setStatusResponseWhileBlocked = false + var clearStatusResponseOK: Bool? + var clearStatusErrorCode: String? + var clearStatusErrorMessage: String? + var clearStatusResponseWhileBlocked = false + var errorDescription: String? +} + +private struct PendingSurfaceWaitObservation: Sendable { + var completed = false + var responseOK = false + var condition: String? + var waited: Bool? + var state: String? + var source: String? + var workspaceID: String? + var surfaceID: String? + var errorDescription: String? +} + +private actor AgentPortPublicationGate { + private var isReleased = false + private var releaseContinuation: CheckedContinuation? + + func pause() async { + await withCheckedContinuation { continuation in + if isReleased { + continuation.resume() + } else { + releaseContinuation = continuation + } + } + } + + func release() { + isReleased = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + @MainActor final class TerminalControllerSocketSecurityTests: XCTestCase { private func makeSocketPath(_ name: String) -> String { @@ -20,11 +142,17 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { override func setUp() { super.setUp() + #if DEBUG + TerminalController.shared.setSocketPasswordCredentialSourceForTesting(nil) + #endif TerminalController.shared.stop() } override func tearDown() { TerminalController.shared.stop() + #if DEBUG + TerminalController.shared.setSocketPasswordCredentialSourceForTesting(nil) + #endif super.tearDown() } @@ -36,6 +164,517 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { } } + private func makeIsolatedSurfaceTelemetryFixture() async throws -> ( + tabManager: TabManager, + workspace: Workspace, + surfaceId: UUID, + directoryURL: URL + ) { + let directoryURL = FileManager.default.temporaryDirectory.appendingPathComponent( + "programa-surface-telemetry-nonrepo-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + do { + let tabManager = TabManager(initialWorkingDirectory: directoryURL.path) + let workspace = try XCTUnwrap(tabManager.selectedWorkspace) + let surfaceId = try XCTUnwrap(workspace.focusedPanelId) + + // The initial git probe applies its directory snapshot asynchronously via an + // unstructured `Task { @MainActor in ... }` hop from a background probe queue. + // That hop is only ever serviced by suspending this async test's own Task (so the + // MainActor executor can drain its queue) -- a synchronous XCTWaiter/run-loop spin + // (the old `waitUntil` helper) never observes it and hangs for the full timeout, + // because XCTWaiter's nested CFRunLoop does not pump Swift Concurrency's MainActor + // executor. Poll with real suspension points instead. Seed the same directory value + // first, then wait for the non-repository probe to finish so no setup publication + // can race the exact objectWillChange counts below. + workspace.updatePanelDirectory(panelId: surfaceId, directory: directoryURL.path) + let deadline = Date().addingTimeInterval(12.0) + while !tabManager.activeWorkspaceGitProbePanelIdsForTesting(workspaceId: workspace.id).isEmpty { + guard Date() < deadline else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT), userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for the isolated workspace git probe", + ]) + } + try await Task.sleep(nanoseconds: 20_000_000) + } + + return (tabManager, workspace, surfaceId, directoryURL) + } catch { + try? FileManager.default.removeItem(at: directoryURL) + throw error + } + } + +#if DEBUG + func testDebugCaptureLabelsCannotEscapeTheScreenshotDirectory() { + let expectedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-screenshots", isDirectory: true) + .standardizedFileURL + let captureID = "capture-id" + let untrustedLabels = [ + "../escaped", + "../../escaped", + "nested/escaped", + "nested\\escaped", + "..", + ] + + for label in untrustedLabels { + let outputURL = TerminalController.debugCaptureOutputURL( + label: label, + captureID: captureID + ).standardizedFileURL + + XCTAssertEqual( + outputURL.deletingLastPathComponent(), + expectedDirectory, + "debug capture labels are caller-controlled and must never select a parent or nested directory: \(label)" + ) + XCTAssertEqual( + outputURL.pathComponents.count, + expectedDirectory.pathComponents.count + 1, + "a capture must always be one direct file child of the screenshot directory: \(label)" + ) + XCTAssertFalse(outputURL.lastPathComponent.contains("..")) + XCTAssertFalse(outputURL.lastPathComponent.contains("/")) + XCTAssertFalse(outputURL.lastPathComponent.contains("\\")) + XCTAssertTrue(outputURL.lastPathComponent.hasSuffix("_\(captureID).png")) + } + } + + func testDebugCapturePreservesAFilesystemSafeLabel() { + let outputURL = TerminalController.debugCaptureOutputURL( + label: "release-compare", + captureID: "capture-id" + ) + + XCTAssertEqual( + outputURL.lastPathComponent, + "release-compare_capture-id.png", + "confining untrusted labels must not discard an already-safe label used to identify a capture" + ) + } + + /// A long-lived wait must occupy only its own client connection. Exact model queries from + /// another client still need prompt main-actor access while that wait remains registered. + func testPendingSurfaceWaitDoesNotBlockExactSurfaceQueryOnAnotherClient() async throws { + let fixture = try await makeIsolatedSurfaceTelemetryFixture() + let socketPath = makeSocketPath("surface-wait") + let workspaceID = fixture.workspace.id.uuidString + let surfaceID = fixture.surfaceId.uuidString + + XCTAssertTrue( + fixture.tabManager.updateSurfaceAgentState( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + state: .idle, + source: .hooks + ) + ) + XCTAssertEqual(fixture.workspace.panelAgentStates[fixture.surfaceId], .idle) + XCTAssertEqual(fixture.workspace.panelAgentStateSources[fixture.surfaceId], .hooks) + + TerminalController.shared.start( + tabManager: fixture.tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + defer { + _ = fixture.tabManager.updateSurfaceAgentState( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + state: .working, + source: .hooks + ) + TerminalController.shared.stop() + try? FileManager.default.removeItem(at: fixture.directoryURL) + } + try waitForSocket(at: socketPath) + + let waitObservation = OSAllocatedUnfairLock(initialState: PendingSurfaceWaitObservation()) + let waitFinished = expectation(description: "surface.wait returned after the public state report") + DispatchQueue.global(qos: .userInitiated).async { + defer { + waitObservation.withLock { $0.completed = true } + waitFinished.fulfill() + } + + do { + let response = try self.sendV2Request( + method: "surface.wait", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "agent_state": "working", + "timeout_ms": 5_000, + ], + to: socketPath + ) + let result = response["result"] as? [String: Any] + waitObservation.withLock { + $0.responseOK = response["ok"] as? Bool == true + $0.condition = result?["condition"] as? String + $0.waited = result?["waited"] as? Bool + $0.state = result?["state"] as? String + $0.source = result?["source"] as? String + $0.workspaceID = result?["workspace_id"] as? String + $0.surfaceID = result?["surface_id"] as? String + } + } catch { + waitObservation.withLock { $0.errorDescription = String(describing: error) } + } + } + + let registrationDeadline = Date().addingTimeInterval(2.0) + while !AgentStateWaitRegistry.shared.hasPendingWaiterForTesting( + surfaceId: fixture.surfaceId, + condition: .working + ), Date() < registrationDeadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + + guard AgentStateWaitRegistry.shared.hasPendingWaiterForTesting( + surfaceId: fixture.surfaceId, + condition: .working + ) else { + _ = fixture.tabManager.updateSurfaceAgentState( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + state: .working, + source: .hooks + ) + await fulfillment(of: [waitFinished], timeout: 6.0) + XCTFail("surface.wait did not register its working-state waiter before the deadline") + return + } + XCTAssertFalse( + waitObservation.withLock { $0.completed }, + "Registration must be observed while client A is still pending" + ) + + let currentResponse: [String: Any] + do { + currentResponse = try await sendV2RequestAsync( + method: "surface.current", + params: ["workspace_id": workspaceID], + to: socketPath + ) + } catch { + _ = fixture.tabManager.updateSurfaceAgentState( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + state: .working, + source: .hooks + ) + await fulfillment(of: [waitFinished], timeout: 6.0) + throw error + } + let currentResult = currentResponse["result"] as? [String: Any] + XCTAssertTrue(currentResponse["ok"] as? Bool == true) + XCTAssertEqual(currentResult?["workspace_id"] as? String, workspaceID) + XCTAssertEqual(currentResult?["surface_id"] as? String, surfaceID) + XCTAssertEqual(currentResult?["surface_type"] as? String, "terminal") + XCTAssertFalse( + waitObservation.withLock { $0.completed }, + "Client A must remain pending when client B receives the exact surface snapshot" + ) + + let reportResponse: [String: Any] + do { + reportResponse = try await sendV2RequestAsync( + method: "surface.report_agent_state", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "state": "working", + "source": "hooks", + ], + to: socketPath + ) + } catch { + _ = fixture.tabManager.updateSurfaceAgentState( + tabId: fixture.workspace.id, + surfaceId: fixture.surfaceId, + state: .working, + source: .hooks + ) + await fulfillment(of: [waitFinished], timeout: 6.0) + throw error + } + let reportResult = reportResponse["result"] as? [String: Any] + XCTAssertTrue(reportResponse["ok"] as? Bool == true) + XCTAssertEqual(reportResult?["workspace_id"] as? String, workspaceID) + XCTAssertEqual(reportResult?["surface_id"] as? String, surfaceID) + XCTAssertEqual(reportResult?["state"] as? String, "working") + XCTAssertEqual(reportResult?["source"] as? String, "hooks") + + await fulfillment(of: [waitFinished], timeout: 6.0) + + let observation = waitObservation.withLock { $0 } + XCTAssertNil(observation.errorDescription) + XCTAssertTrue(observation.completed) + XCTAssertTrue(observation.responseOK) + XCTAssertEqual(observation.condition, "agent_state") + XCTAssertEqual(observation.waited, true) + XCTAssertEqual(observation.state, "working") + XCTAssertEqual(observation.source, "hooks") + XCTAssertEqual(observation.workspaceID, workspaceID) + XCTAssertEqual(observation.surfaceID, surfaceID) + XCTAssertFalse( + AgentStateWaitRegistry.shared.hasPendingWaiterForTesting( + surfaceId: fixture.surfaceId, + condition: .working + ) + ) + } +#endif + + func testDuplicateSurfacePortsReportDoesNotRepublishWorkspace() async throws { + let fixture = try await makeIsolatedSurfaceTelemetryFixture() + let tabManager = fixture.tabManager + let workspace = fixture.workspace + let surfaceId = fixture.surfaceId + let reportedPorts = [4242, 5173] + + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: makeSocketPath("ports-dedup"), + accessMode: .allowAll + ) + + _ = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": reportedPorts, + ]) + await drainMainQueue() + XCTAssertEqual(workspace.surfaceListeningPorts[surfaceId], reportedPorts) + XCTAssertEqual(workspace.listeningPorts, reportedPorts) + + var publishCount = 0 + let cancellable = workspace.objectWillChange.sink { _ in + publishCount += 1 + } + defer { cancellable.cancel() } + + _ = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": reportedPorts, + ]) + await drainMainQueue() + + XCTAssertEqual( + publishCount, + 0, + "An identical ports report must not invalidate every workspace observer" + ) + XCTAssertEqual(workspace.surfaceListeningPorts[surfaceId], reportedPorts) + XCTAssertEqual(workspace.listeningPorts, reportedPorts) + } + + func testSurfacePortsReportEnforcesInclusiveCountBoundThroughSocket() async throws { + let fixture = try await makeIsolatedSurfaceTelemetryFixture() + let workspace = fixture.workspace + let surfaceId = fixture.surfaceId + let socketPath = makeSocketPath("ports-bound") + let maximumReportedPorts = 65_535 + let acceptedPorts = Array(1...maximumReportedPorts) + + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + TerminalController.shared.start( + tabManager: fixture.tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let acceptedResult = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": acceptedPorts, + ]) + guard case .ok = acceptedResult else { + XCTFail("The inclusive reported-port count limit must remain accepted") + return + } + await drainMainQueue() + XCTAssertTrue( + workspace.surfaceListeningPorts[surfaceId] == acceptedPorts, + "An accepted boundary-sized report must reach the surface telemetry model" + ) + + let oversizedResponse = try await sendV2RequestAsync( + method: "surface.report_ports", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": Array(repeating: 5173, count: maximumReportedPorts + 1), + ], + to: socketPath + ) + let oversizedError = oversizedResponse["error"] as? [String: Any] + XCTAssertEqual( + oversizedResponse["ok"] as? Bool, + false, + "A report above the count limit must be rejected before model mutation" + ) + XCTAssertEqual(oversizedError?["code"] as? String, "invalid_params") + + await drainMainQueue() + XCTAssertTrue( + workspace.surfaceListeningPorts[surfaceId] == acceptedPorts, + "Rejected ingress must not replace the last accepted surface ports after main-queue work drains" + ) + } + + func testSurfacePortsReportCanonicalizesDuplicateUnorderedPortsThroughSocket() async throws { + let fixture = try await makeIsolatedSurfaceTelemetryFixture() + let workspace = fixture.workspace + let surfaceId = fixture.surfaceId + let socketPath = makeSocketPath("ports-canonical") + let canonicalPorts = [3000, 5173] + + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + TerminalController.shared.start( + tabManager: fixture.tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let response = try await sendV2RequestAsync( + method: "surface.report_ports", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": [5173, 3000, 5173], + ], + to: socketPath + ) + let result = response["result"] as? [String: Any] + XCTAssertEqual(response["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(response)") + XCTAssertEqual( + result?["ports"] as? [Int], + canonicalPorts, + "The acknowledgement must expose the set semantics of reported listening ports" + ) + XCTAssertTrue( + waitUntil { workspace.surfaceListeningPorts[surfaceId] == canonicalPorts }, + "Surface telemetry must store ports in the same canonical form returned to callers" + ) + XCTAssertEqual(workspace.listeningPorts, canonicalPorts) + } + + func testSurfacePortsReportPrunesOnlyChangedPublishedMetadata() async throws { + let fixture = try await makeIsolatedSurfaceTelemetryFixture() + let tabManager = fixture.tabManager + let workspace = fixture.workspace + let surfaceId = fixture.surfaceId + let staleSurfaceId = UUID() + let reportedPorts = [4242, 5173] + + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: makeSocketPath("ports-prune"), + accessMode: .allowAll + ) + + _ = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": reportedPorts, + ]) + await drainMainQueue() + + workspace.panelTitles[surfaceId] = "Valid surface" + workspace.panelTitles[staleSurfaceId] = "Closed surface" + + var publishCount = 0 + let cancellable = workspace.objectWillChange.sink { _ in + publishCount += 1 + } + defer { cancellable.cancel() } + + _ = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": reportedPorts, + ]) + await drainMainQueue() + + XCTAssertNil(workspace.panelTitles[staleSurfaceId]) + XCTAssertEqual(workspace.panelTitles[surfaceId], "Valid surface") + XCTAssertEqual(workspace.surfaceListeningPorts[surfaceId], reportedPorts) + XCTAssertEqual(workspace.listeningPorts, reportedPorts) + XCTAssertEqual( + publishCount, + 1, + "Pruning one stale published collection must emit once without republishing unchanged collections" + ) + } + + func testSurfacePortsReportPrunesStaleTTYAndPortsWithoutDisturbingLiveSurface() async throws { + let fixture = try await makeIsolatedSurfaceTelemetryFixture() + let tabManager = fixture.tabManager + let workspace = fixture.workspace + let surfaceId = fixture.surfaceId + let staleSurfaceId = UUID() + let reportedPorts = [4242, 5173] + + defer { try? FileManager.default.removeItem(at: fixture.directoryURL) } + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: makeSocketPath("tty-port-prune"), + accessMode: .allowAll + ) + + _ = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": reportedPorts, + ]) + await drainMainQueue() + + workspace.surfaceTTYNames[surfaceId] = "ttys-live" + workspace.surfaceTTYNames[staleSurfaceId] = "ttys-stale" + workspace.surfaceListeningPorts[staleSurfaceId] = reportedPorts + + var publishCount = 0 + let cancellable = workspace.objectWillChange.sink { _ in + publishCount += 1 + } + defer { cancellable.cancel() } + + _ = TerminalController.shared.v2SurfaceReportPorts(params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + "ports": reportedPorts, + ]) + await drainMainQueue() + + XCTAssertNil(workspace.surfaceTTYNames[staleSurfaceId]) + XCTAssertNil(workspace.surfaceListeningPorts[staleSurfaceId]) + XCTAssertEqual(workspace.surfaceTTYNames[surfaceId], "ttys-live") + XCTAssertEqual(workspace.surfaceListeningPorts[surfaceId], reportedPorts) + XCTAssertEqual(workspace.listeningPorts, reportedPorts) + XCTAssertEqual( + publishCount, + 1, + "Removing stale TTY bookkeeping and one stale published ports entry must emit only for the published change" + ) + } + func testClearingEmptyWorkspaceTelemetryDoesNotRepublishWorkspace() async { let tabManager = TabManager() let workspace = tabManager.addWorkspace(select: true, eagerLoadTerminal: false) @@ -102,6 +741,107 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { ) } + func testExplicitInvalidWorkspaceSelectorCannotReadOrMutateSelectedWorkspaceThroughSocket() async throws { + let socketPath = makeSocketPath("invalid-workspace") + let manager = TabManager() + let selectedWorkspace = try XCTUnwrap(manager.selectedWorkspace) + let otherWorkspace = manager.addWorkspace(select: false, eagerLoadTerminal: false) + let selectedStatus = SidebarStatusEntry(key: "selected-status", value: "selected-running") + let selectedMetadata = SidebarMetadataBlock( + key: "selected-metadata", + markdown: "selected-details", + priority: 7, + timestamp: Date(timeIntervalSince1970: 1) + ) + let selectedLog = SidebarLogEntry( + message: "selected-log", + level: .warning, + source: "selected-source", + timestamp: Date(timeIntervalSince1970: 2) + ) + let selectedProgress = SidebarProgressState(value: 0.75, label: "selected-progress") + + selectedWorkspace.statusEntries[selectedStatus.key] = selectedStatus + selectedWorkspace.metadataBlocks[selectedMetadata.key] = selectedMetadata + selectedWorkspace.logEntries = [selectedLog] + selectedWorkspace.progress = selectedProgress + otherWorkspace.statusEntries["other-status"] = SidebarStatusEntry( + key: "other-status", + value: "other-running" + ) + otherWorkspace.metadataBlocks["other-metadata"] = SidebarMetadataBlock( + key: "other-metadata", + markdown: "other-details", + priority: 3, + timestamp: Date(timeIntervalSince1970: 3) + ) + + XCTAssertEqual(manager.selectedTabId, selectedWorkspace.id) + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + for invalidWorkspaceID in ["not-a-workspace-id", "workspace:0", UUID().uuidString] { + let response = try await sendV2RequestAsync( + method: "workspace.list_status", + params: ["workspace_id": invalidWorkspaceID], + to: socketPath + ) + + XCTAssertEqual( + response["ok"] as? Bool, + false, + "An explicit invalid workspace_id must not fall back to the selected workspace: \(response)" + ) + XCTAssertNotNil(response["error"], "Expected an error response for \(invalidWorkspaceID)") + XCTAssertEqual(selectedWorkspace.statusEntries[selectedStatus.key], selectedStatus) + } + + let clearResponse = try await sendV2RequestAsync( + method: "workspace.clear_meta_block", + params: [ + "workspace_id": "not-a-workspace-id", + "key": selectedMetadata.key, + ], + to: socketPath + ) + + XCTAssertEqual( + clearResponse["ok"] as? Bool, + false, + "An explicit malformed workspace_id must not clear metadata from the selected workspace: \(clearResponse)" + ) + XCTAssertNotNil(clearResponse["error"]) + XCTAssertEqual(selectedWorkspace.metadataBlocks[selectedMetadata.key], selectedMetadata) + + // Restore the sentinel independently so reset_sidebar proves its own destructive boundary + // even when the pre-fix clear_meta_block assertion above records a failure and removes it. + selectedWorkspace.metadataBlocks[selectedMetadata.key] = selectedMetadata + + let resetResponse = try await sendV2RequestAsync( + method: "workspace.reset_sidebar", + params: ["workspace_id": "not-a-workspace-id"], + to: socketPath + ) + + XCTAssertEqual( + resetResponse["ok"] as? Bool, + false, + "An explicit malformed workspace_id must not reset the selected workspace: \(resetResponse)" + ) + XCTAssertNotNil(resetResponse["error"]) + XCTAssertEqual(selectedWorkspace.statusEntries[selectedStatus.key], selectedStatus) + XCTAssertEqual(selectedWorkspace.metadataBlocks[selectedMetadata.key], selectedMetadata) + XCTAssertEqual(selectedWorkspace.logEntries, [selectedLog]) + XCTAssertEqual(selectedWorkspace.progress, selectedProgress) + XCTAssertEqual(otherWorkspace.statusEntries["other-status"]?.value, "other-running") + XCTAssertEqual(otherWorkspace.metadataBlocks["other-metadata"]?.markdown, "other-details") + } + /// Regression for #6618: `shouldPublishShellActivity` used to record the state /// it was queried with (write-on-read). When a report arrived before the panel /// existed, that premature write suppressed every later identical report, so the @@ -130,33 +870,147 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertFalse(fastPath.shouldPublishShellActivity( workspaceId: workspaceId, panelId: panelId, state: idle)) - // A different state always publishes. - XCTAssertTrue(fastPath.shouldPublishShellActivity( - workspaceId: workspaceId, panelId: panelId, state: .commandRunning)) + // A different state always publishes. + XCTAssertTrue(fastPath.shouldPublishShellActivity( + workspaceId: workspaceId, panelId: panelId, state: .commandRunning)) + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testStopRevokesEstablishedAllowAllClient() throws { + let socketPath = makeSocketPath("stop-revocation") + TerminalController.shared.start( + tabManager: TabManager(), + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let clientFD = try connectPersistentClient(to: socketPath) + defer { Darwin.close(clientFD) } + + let initialResponse = try sendV2Ping(to: clientFD, id: 1) + XCTAssertTrue( + isSuccessfulV2Ping(initialResponse), + "The established client must reach the real JSON-RPC handler before revocation is tested" + ) + + TerminalController.shared.stop() + + let postStopResponse = try? sendV2Ping(to: clientFD, id: 2, timeout: 1.0) + XCTAssertFalse( + postStopResponse.map(isSuccessfulV2Ping) ?? false, + "Stopping socket control must revoke established clients, not only reject new connections" + ) + } + + func testRestartOnSamePathRevokesOldClientAndAcceptsNewClient() throws { + let socketPath = makeSocketPath("restart-revocation") + let tabManager = TabManager() + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let oldClientFD = try connectPersistentClient(to: socketPath) + defer { Darwin.close(oldClientFD) } + + let initialResponse = try sendV2Ping(to: oldClientFD, id: 1) + XCTAssertTrue( + isSuccessfulV2Ping(initialResponse), + "The old client must be established before the listener restarts" + ) + + TerminalController.shared.stop() + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let oldClientResponse = try? sendV2Ping(to: oldClientFD, id: 2, timeout: 1.0) + XCTAssertFalse( + oldClientResponse.map(isSuccessfulV2Ping) ?? false, + "Restarting the listener must not preserve authority held by a client from the previous listener" + ) + + let newClientResponse = try sendV2Request( + method: "system.ping", + params: [:], + to: socketPath + ) + XCTAssertTrue( + isSuccessfulV2Ping(newClientResponse), + "The restarted listener must accept newly connected clients" + ) } - func testSocketPermissionsFollowAccessMode() throws { + func testAccessModeChangeOnSamePathRevokesOldClientAndAcceptsNewClient() throws { + let socketPath = makeSocketPath("mode-revocation") let tabManager = TabManager() - - let allowAllPath = makeSocketPath("allow-all") TerminalController.shared.start( tabManager: tabManager, - socketPath: allowAllPath, + socketPath: socketPath, accessMode: .allowAll ) - try waitForSocket(at: allowAllPath) - XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + try waitForSocket(at: socketPath) - TerminalController.shared.stop() + let oldClientFD = try connectPersistentClient(to: socketPath) + defer { Darwin.close(oldClientFD) } + + let initialResponse = try sendV2Ping(to: oldClientFD, id: 1) + XCTAssertTrue( + isSuccessfulV2Ping(initialResponse), + "The old client must be established under the original access mode" + ) - let restrictedPath = makeSocketPath("cmux-only") TerminalController.shared.start( tabManager: tabManager, - socketPath: restrictedPath, - accessMode: .cmuxOnly + socketPath: socketPath, + accessMode: .automation + ) + try waitForSocket(at: socketPath) + + let oldClientResponse = try? sendV2Ping(to: oldClientFD, id: 2, timeout: 1.0) + XCTAssertFalse( + oldClientResponse.map(isSuccessfulV2Ping) ?? false, + "Changing access mode must revoke authority granted by the previous mode" + ) + + let newClientResponse = try sendV2Request( + method: "system.ping", + params: [:], + to: socketPath + ) + XCTAssertTrue( + isSuccessfulV2Ping(newClientResponse), + "The replacement listener must accept clients under the new access mode" ) - try waitForSocket(at: restrictedPath) - XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) } func testPasswordModeRejectsUnauthenticatedCommands() throws { @@ -184,6 +1038,111 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) } + #if DEBUG + func testPasswordRotationRevokesAuthenticatedClientAndRequiresNewCredential() throws { + let oldPassword = "old-test-password" + let newPassword = "new-test-password" + let credentials = TestSocketPasswordCredentialHolder(password: oldPassword) + TerminalController.shared.setSocketPasswordCredentialSourceForTesting(credentials.source) + + let socketPath = makeSocketPath("password-rotation") + TerminalController.shared.start( + tabManager: TabManager(), + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let oldClientFD = try connectPersistentClient(to: socketPath) + defer { Darwin.close(oldClientFD) } + + let oldAuthentication = try sendV2Request( + method: "auth.login", + params: ["password": oldPassword], + id: 1, + to: oldClientFD + ) + XCTAssertTrue( + isSuccessfulV2Authentication(oldAuthentication), + "The persistent client must authenticate with the credential active when it connects" + ) + XCTAssertTrue( + isSuccessfulV2Ping(try sendV2Ping(to: oldClientFD, id: 2)), + "An authenticated password-mode client must be able to execute commands before rotation" + ) + + credentials.update(password: newPassword) + NotificationCenter.default.post( + name: SocketControlPasswordStore.didChangeNotification, + object: nil + ) + + let revokedClientResponse = try? sendV2Ping(to: oldClientFD, id: 3, timeout: 1.0) + XCTAssertFalse( + revokedClientResponse.map(isSuccessfulV2Ping) ?? false, + "Rotating the socket password must revoke clients authenticated with the previous credential" + ) + + let newClientFD = try connectPersistentClient(to: socketPath) + defer { Darwin.close(newClientFD) } + + let staleAuthentication = try sendV2Request( + method: "auth.login", + params: ["password": oldPassword], + id: 4, + to: newClientFD + ) + XCTAssertEqual( + v2ErrorCode(staleAuthentication), + "auth_failed", + "A new client must not authenticate with the credential that was rotated away" + ) + + let newAuthentication = try sendV2Request( + method: "auth.login", + params: ["password": newPassword], + id: 5, + to: newClientFD + ) + XCTAssertTrue( + isSuccessfulV2Authentication(newAuthentication), + "A new client must authenticate with the replacement credential" + ) + XCTAssertTrue( + isSuccessfulV2Ping(try sendV2Ping(to: newClientFD, id: 6)), + "A client authenticated after rotation must retain normal command access" + ) + } + #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 @@ -296,63 +1255,476 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { let originalTabManager = appDelegate.tabManager let originalNotificationStore = appDelegate.notificationStore - store.replaceNotificationsForTesting([]) - store.configureNotificationDeliveryHandlerForTesting { _, _ in } - appDelegate.tabManager = manager - appDelegate.notificationStore = store + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + } + + guard let focusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a focused panel") + return + } + guard let targetPanel = workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal) else { + XCTFail("Expected split panel to be created") + return + } + workspace.focusPanel(focusedPanelId) + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let response = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + let response = try self.sendV2Request( + method: "notification.create", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": targetPanel.id.uuidString, + "title": "Targeted" + ], + to: socketPath + ) + continuation.resume(returning: response) + } catch { + continuation.resume(throwing: error) + } + } + } + + XCTAssertEqual(response["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(response)") + let result = try XCTUnwrap(response["result"] as? [String: Any], "Unexpected JSON-RPC response: \(response)") + XCTAssertEqual(result["surface_id"] as? String, targetPanel.id.uuidString) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: targetPanel.id)) + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) + } + + /// High-frequency telemetry must acknowledge independently of the main queue so shell-side + /// reporters cannot stall behind UI work. The model mutation remains main-queue confined and + /// is applied only after that queue becomes available. + func testWorkspaceStatusTelemetryAcknowledgesWhileMainQueueIsOccupiedThenMutatesAfterRelease() async throws { + let socketPath = makeSocketPath("status-lane") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true, eagerLoadTerminal: false) + let workspaceID = workspace.id.uuidString + let statusKey = "build" + let statusValue = "running" + + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + defer { TerminalController.shared.stop() } + try waitForSocket(at: socketPath) + + let clientFD = try connectPersistentClient(to: socketPath) + defer { + _ = Darwin.shutdown(clientFD, SHUT_RDWR) + Darwin.close(clientFD) + } + + let statusPublicationCount = OSAllocatedUnfairLock(initialState: 0) + let statusCancellable = workspace.$statusEntries.dropFirst().sink { _ in + statusPublicationCount.withLock { $0 += 1 } + } + defer { statusCancellable.cancel() } + + let mainQueueEntered = DispatchSemaphore(value: 0) + let mainQueueRelease = DispatchSemaphore(value: 0) + defer { mainQueueRelease.signal() } + + let mainQueueBlockReturned = OSAllocatedUnfairLock(initialState: false) + let mainQueueBlockTimedOut = OSAllocatedUnfairLock(initialState: false) + let observation = OSAllocatedUnfairLock(initialState: MainQueueBlockedTelemetryObservation()) + let clientFinished = expectation(description: "telemetry response received while main queue is occupied") + + DispatchQueue.main.async { + mainQueueEntered.signal() + let waitResult = mainQueueRelease.wait(timeout: .now() + 3.0) + mainQueueBlockTimedOut.withLock { $0 = waitResult == .timedOut } + mainQueueBlockReturned.withLock { $0 = true } + } + + DispatchQueue.global(qos: .userInitiated).async { + defer { + mainQueueRelease.signal() + clientFinished.fulfill() + } + + guard mainQueueEntered.wait(timeout: .now() + 1.0) == .success else { + observation.withLock { + $0.errorDescription = "Timed out waiting for the main-queue blocker to start" + } + return + } + + do { + let response = try self.sendV2Request( + method: "workspace.set_status", + params: [ + "workspace_id": workspaceID, + "key": statusKey, + "value": statusValue, + ], + id: 1, + to: clientFD, + timeout: 1.0 + ) + let result = response["result"] as? [String: Any] + let queueWasStillBlocked = !mainQueueBlockReturned.withLock { $0 } + let publicationsBeforeRelease = statusPublicationCount.withLock { $0 } + + observation.withLock { + $0.responseReceived = true + $0.responseOK = response["ok"] as? Bool == true + $0.responseWorkspaceID = result?["workspace_id"] as? String + $0.responseKey = result?["key"] as? String + $0.responseValue = result?["value"] as? String + $0.mainQueueWasBlockedAtResponse = queueWasStillBlocked + $0.statusPublicationCountBeforeRelease = publicationsBeforeRelease + } + } catch { + observation.withLock { + $0.errorDescription = String(describing: error) + } + } + } + + await fulfillment(of: [clientFinished], timeout: 5.0) + + let responseObservation = observation.withLock { $0 } + XCTAssertNil(responseObservation.errorDescription) + XCTAssertTrue( + responseObservation.responseReceived, + "Telemetry must respond before a busy main queue is released" + ) + XCTAssertTrue(responseObservation.responseOK) + XCTAssertEqual(responseObservation.responseWorkspaceID, workspaceID) + XCTAssertEqual(responseObservation.responseKey, statusKey) + XCTAssertEqual(responseObservation.responseValue, statusValue) + XCTAssertTrue( + responseObservation.mainQueueWasBlockedAtResponse, + "The optimistic response must not wait for main-queue model resolution" + ) + XCTAssertEqual( + responseObservation.statusPublicationCountBeforeRelease, + 0, + "The workspace mutation must remain deferred while the main queue is occupied" + ) + XCTAssertFalse( + mainQueueBlockTimedOut.withLock { $0 }, + "The client must release the bounded main-queue blocker after receiving its response" + ) + + await drainMainQueue() + XCTAssertEqual( + workspace.statusEntries[statusKey]?.value, + statusValue, + "The acknowledged telemetry mutation must apply after the main queue is released" + ) + } + + /// Exact UI/model queries must wait for the main queue so their response describes one + /// coherent point in time rather than racing window-context mutation. + func testWindowListWaitsForMainQueueBeforeReturningExactSnapshot() async throws { + let originalAppDelegate = AppDelegate.shared + let isolatedAppDelegate = AppDelegate() + defer { + if AppDelegate.shared === isolatedAppDelegate { + AppDelegate.shared = originalAppDelegate + } + } + + let socketPath = makeSocketPath("window-snapshot") + TerminalController.shared.start( + tabManager: TabManager(), + socketPath: socketPath, + accessMode: .allowAll + ) + defer { TerminalController.shared.stop() } + try waitForSocket(at: socketPath) + + let clientFD = try connectPersistentClient(to: socketPath) + defer { + _ = Darwin.shutdown(clientFD, SHUT_RDWR) + Darwin.close(clientFD) + } + guard isSuccessfulV2Ping(try sendV2Ping(to: clientFD, id: 1)) else { + XCTFail("Expected the persistent Unix client handler to be ready before blocking main") + return + } + guard !TerminalController.shouldSuppressSocketCommandActivation() else { + XCTFail("Expected no socket command policy scope after the preflight response was consumed") + return + } + + let mainQueueEntered = DispatchSemaphore(value: 0) + let mainQueueRelease = DispatchSemaphore(value: 0) + defer { mainQueueRelease.signal() } + + let mainQueueBlockTimedOut = OSAllocatedUnfairLock(initialState: false) + let observation = OSAllocatedUnfairLock(initialState: MainQueueBlockedQueryObservation()) + let clientFinished = expectation(description: "exact window snapshot received after main queue release") + + DispatchQueue.main.async { + mainQueueEntered.signal() + let waitResult = mainQueueRelease.wait(timeout: .now() + 3.0) + mainQueueBlockTimedOut.withLock { $0 = waitResult == .timedOut } + } + + DispatchQueue.global(qos: .userInitiated).async { + defer { + mainQueueRelease.signal() + clientFinished.fulfill() + } + + guard mainQueueEntered.wait(timeout: .now() + 1.0) == .success else { + observation.withLock { + $0.errorDescription = "Timed out waiting for the main-queue blocker to start" + } + return + } + + do { + try self.writeLine( + #"{"jsonrpc":"2.0","id":2,"method":"window.list","params":{}}"#, + to: clientFD + ) + } catch { + observation.withLock { $0.errorDescription = String(describing: error) } + return + } + + // This class serializes access to the shared TerminalController. With the preflight + // response fully consumed above, a positive policy depth identifies request 2's + // parsed dispatch scope without making that internal signal part of the assertion. + var dispatchEntryObserved = false + let dispatchEntryDeadline = DispatchTime.now() + 1.0 + while !dispatchEntryObserved { + if TerminalController.shouldSuppressSocketCommandActivation() { + dispatchEntryObserved = true + break + } + + let now = DispatchTime.now().uptimeNanoseconds + guard now < dispatchEntryDeadline.uptimeNanoseconds else { + observation.withLock { + $0.errorDescription = "Timed out waiting for window.list to enter parsed dispatch" + } + break + } + + let remainingNanoseconds = dispatchEntryDeadline.uptimeNanoseconds - now + let remainingMilliseconds = max(1, (remainingNanoseconds + 999_999) / 1_000_000) + var descriptor = pollfd(fd: clientFD, events: Int16(POLLIN), revents: 0) + let pollResult = Darwin.poll( + &descriptor, + 1, + Int32(min(UInt64(5), remainingMilliseconds)) + ) + if pollResult > 0 { + observation.withLock { $0.responseArrivedWhileBlocked = true } + break + } + if pollResult < 0, errno != EINTR { + observation.withLock { + $0.errorDescription = String(describing: self.posixError("poll while waiting for parsed dispatch")) + } + break + } + } + + if dispatchEntryObserved { + do { + try self.waitForReadable( + from: clientFD, + until: .now() + 0.2, + operation: "checking for an off-main window.list response" + ) + observation.withLock { $0.responseArrivedWhileBlocked = true } + } catch let error as NSError + where error.domain == NSPOSIXErrorDomain && error.code == Int(ETIMEDOUT) { + observation.withLock { $0.confirmedNoResponseWhileBlocked = true } + } catch { + observation.withLock { $0.errorDescription = String(describing: error) } + } + } + + mainQueueRelease.signal() + + do { + let responseLine = try self.readLine(from: clientFD, timeout: 1.0) + let responseData = Data(responseLine.utf8) + guard let response = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], + let result = response["result"] as? [String: Any], + let windows = result["windows"] as? [[String: Any]] else { + observation.withLock { + $0.errorDescription = "Expected a window.list JSON-RPC result" + } + return + } + observation.withLock { + $0.responseOK = response["ok"] as? Bool == true + $0.windowCount = windows.count + } + } catch { + observation.withLock { $0.errorDescription = String(describing: error) } + } + } + + await fulfillment(of: [clientFinished], timeout: 5.0) + + let queryObservation = observation.withLock { $0 } + XCTAssertNil(queryObservation.errorDescription) + XCTAssertTrue( + queryObservation.confirmedNoResponseWhileBlocked, + "An exact window snapshot must not return while the main queue is occupied" + ) + XCTAssertFalse( + queryObservation.responseArrivedWhileBlocked, + "window.list must not read AppDelegate window state off-main" + ) + XCTAssertTrue(queryObservation.responseOK) + XCTAssertEqual( + queryObservation.windowCount, + 0, + "The isolated delegate's exact snapshot must contain no windows" + ) + XCTAssertFalse( + mainQueueBlockTimedOut.withLock { $0 }, + "The bounded main-queue blocker must be released by the client observation" + ) + } + + /// A successful subscribe response defines the stream boundary: clients must be able to + /// parse that acknowledgment before any asynchronous event frame. Events published after + /// the acknowledgment must then flow on the same connection without another request. + func testSubscribeAcknowledgmentPrecedesPushedEvents() throws { + 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 workspace = manager.addWorkspace(select: true) + let connection = SocketConnection(socket: sockets[0]) defer { - if manager.tabs.contains(where: { $0.id == workspace.id }) { - manager.closeWorkspace(workspace) - } - store.replaceNotificationsForTesting([]) - store.resetNotificationDeliveryHandlerForTesting() - appDelegate.tabManager = originalTabManager - appDelegate.notificationStore = originalNotificationStore + connection.teardown() + _ = Darwin.shutdown(sockets[0], SHUT_RDWR) + _ = Darwin.shutdown(sockets[1], SHUT_RDWR) + Darwin.close(sockets[0]) + Darwin.close(sockets[1]) } - guard let focusedPanelId = workspace.focusedPanelId else { - XCTFail("Expected selected workspace with a focused panel") + let subscribeResult = TerminalController.shared.v2Subscribe( + params: ["classes": ["workspace_lifecycle"]], + connection: connection + ) + guard case .ok(let resultPayload) = subscribeResult else { + XCTFail("Expected workspace_lifecycle subscription to be accepted") return } - guard let targetPanel = workspace.newTerminalSplit(from: focusedPanelId, orientation: .horizontal) else { - XCTFail("Expected split panel to be created") - return + + let acknowledgmentObject: [String: Any] = [ + "id": 1, + "ok": true, + "result": resultPayload, + ] + let acknowledgmentData = try JSONSerialization.data(withJSONObject: acknowledgmentObject) + let acknowledgmentLine = try XCTUnwrap(String(data: acknowledgmentData, encoding: .utf8)) + + let beforeAcknowledgmentWorkspaceID = UUID() + SocketEventBroadcaster.shared.publishWorkspaceLifecycle( + kind: "before_ack", + workspaceId: beforeAcknowledgmentWorkspaceID, + title: nil + ) + + var frameBeforeAcknowledgment: [String: Any]? + do { + try waitForReadable( + from: sockets[1], + until: .now() + 1.0, + operation: "checking for an event before the subscribe acknowledgment" + ) + let line = try readLine(from: sockets[1], timeout: 1.0) + frameBeforeAcknowledgment = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] + ) + } catch let error as NSError + where error.domain == NSPOSIXErrorDomain && error.code == Int(ETIMEDOUT) { + frameBeforeAcknowledgment = nil } - workspace.focusPanel(focusedPanelId) - TerminalController.shared.start( - tabManager: manager, - socketPath: socketPath, - accessMode: .allowAll + XCTAssertTrue( + connection.writeLine(acknowledgmentLine), + "The real subscription acknowledgment must be writable to the live connection" ) - try waitForSocket(at: socketPath) - let response = try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - let response = try self.sendV2Request( - method: "notification.create", - params: [ - "workspace_id": workspace.id.uuidString, - "surface_id": targetPanel.id.uuidString, - "title": "Targeted" - ], - to: socketPath - ) - continuation.resume(returning: response) - } catch { - continuation.resume(throwing: error) - } - } + let afterAcknowledgmentWorkspaceID = UUID() + SocketEventBroadcaster.shared.publishWorkspaceLifecycle( + kind: "after_ack", + workspaceId: afterAcknowledgmentWorkspaceID, + title: nil + ) + + let firstLineAfterAcknowledgmentWrite = try readLine(from: sockets[1], timeout: 1.0) + let firstFrameAfterAcknowledgmentWrite = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(firstLineAfterAcknowledgmentWrite.utf8)) as? [String: Any] + ) + + XCTAssertNil( + frameBeforeAcknowledgment, + "No pushed event may overtake a successful subscribe acknowledgment" + ) + XCTAssertEqual(firstFrameAfterAcknowledgmentWrite["id"] as? Int, 1) + XCTAssertEqual(firstFrameAfterAcknowledgmentWrite["ok"] as? Bool, true) + XCTAssertNil( + firstFrameAfterAcknowledgmentWrite["event"], + "The first stream frame must be the subscribe acknowledgment, not an event" + ) + + let nextLine = try readLine(from: sockets[1], timeout: 1.0) + var postAcknowledgmentFrame = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(nextLine.utf8)) as? [String: Any] + ) + if postAcknowledgmentFrame["workspace_id"] as? String != afterAcknowledgmentWorkspaceID.uuidString { + let followingLine = try readLine(from: sockets[1], timeout: 1.0) + postAcknowledgmentFrame = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(followingLine.utf8)) as? [String: Any] + ) } - XCTAssertEqual(response["ok"] as? Bool, true, "Unexpected JSON-RPC response: \(response)") - let result = try XCTUnwrap(response["result"] as? [String: Any], "Unexpected JSON-RPC response: \(response)") - XCTAssertEqual(result["surface_id"] as? String, targetPanel.id.uuidString) - XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: targetPanel.id)) - XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: focusedPanelId)) + XCTAssertEqual(postAcknowledgmentFrame["event"] as? String, "workspace_lifecycle") + XCTAssertEqual(postAcknowledgmentFrame["kind"] as? String, "after_ack") + XCTAssertEqual( + postAcknowledgmentFrame["workspace_id"] as? String, + afterAcknowledgmentWorkspaceID.uuidString, + "An event published after acknowledgment must flow on the activated subscription" + ) } /// Regression for #82: `surface.report_tty`/`surface.ports_kick` used to block the socket @@ -468,6 +1840,369 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertTrue(workspace.surfaceTTYNames.isEmpty) } + /// Unknown handles are rejected from the telemetry lane's existing handle cache. Validation + /// must not fall back to a generic main-actor handle refresh, or malformed telemetry can + /// stall behind unrelated UI work instead of failing promptly with an actionable field name. + func testCacheOnlyTelemetryRejectsUnknownHandlesWhileMainQueueIsOccupied() async throws { + let socketPath = makeSocketPath("relay-handles") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true, eagerLoadTerminal: false) + let workspaceID = workspace.id.uuidString + + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + defer { TerminalController.shared.stop() } + try waitForSocket(at: socketPath) + + let clientFD = try connectPersistentClient(to: socketPath) + defer { + _ = Darwin.shutdown(clientFD, SHUT_RDWR) + Darwin.close(clientFD) + } + guard isSuccessfulV2Ping(try sendV2Ping(to: clientFD, id: 1)) else { + XCTFail("Expected the persistent Unix client handler to be ready before blocking main") + return + } + + let mainQueueEntered = DispatchSemaphore(value: 0) + let mainQueueRelease = DispatchSemaphore(value: 0) + defer { mainQueueRelease.signal() } + + let mainQueueBlockReturned = OSAllocatedUnfairLock(initialState: false) + let mainQueueBlockTimedOut = OSAllocatedUnfairLock(initialState: false) + let observation = OSAllocatedUnfairLock(initialState: MainQueueBlockedInvalidTelemetryObservation()) + let clientFinished = expectation(description: "unknown telemetry handles rejected while main queue is occupied") + + DispatchQueue.main.async { + mainQueueEntered.signal() + let waitResult = mainQueueRelease.wait(timeout: .now() + 3.0) + mainQueueBlockTimedOut.withLock { $0 = waitResult == .timedOut } + mainQueueBlockReturned.withLock { $0 = true } + } + + DispatchQueue.global(qos: .userInitiated).async { + defer { + mainQueueRelease.signal() + clientFinished.fulfill() + } + + guard mainQueueEntered.wait(timeout: .now() + 1.0) == .success else { + observation.withLock { + $0.errorDescription = "Timed out waiting for the main-queue blocker to start" + } + return + } + + do { + let reportTTYResponse = try self.sendV2Request( + method: "surface.report_tty", + params: [ + "workspace_id": workspaceID, + "surface_id": "surface:0", + "tty_name": "ttys999", + ], + id: 2, + to: clientFD, + timeout: 1.0 + ) + let reportTTYError = reportTTYResponse["error"] as? [String: Any] + observation.withLock { + $0.reportTTYResponseOK = reportTTYResponse["ok"] as? Bool + $0.reportTTYErrorCode = reportTTYError?["code"] as? String + $0.reportTTYErrorMessage = reportTTYError?["message"] as? String + $0.reportTTYResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let portsKickResponse = try self.sendV2Request( + method: "surface.ports_kick", + params: ["workspace_id": "workspace:0"], + id: 3, + to: clientFD, + timeout: 1.0 + ) + let portsKickError = portsKickResponse["error"] as? [String: Any] + observation.withLock { + $0.portsKickResponseOK = portsKickResponse["ok"] as? Bool + $0.portsKickErrorCode = portsKickError?["code"] as? String + $0.portsKickErrorMessage = portsKickError?["message"] as? String + $0.portsKickResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let reportPWDResponse = try self.sendV2Request( + method: "surface.report_pwd", + params: [ + "workspace_id": workspaceID, + "surface_id": "surface:0", + "path": "/tmp/programa-cache-only-telemetry", + ], + id: 4, + to: clientFD, + timeout: 1.0 + ) + let reportPWDError = reportPWDResponse["error"] as? [String: Any] + observation.withLock { + $0.reportPWDResponseOK = reportPWDResponse["ok"] as? Bool + $0.reportPWDErrorCode = reportPWDError?["code"] as? String + $0.reportPWDErrorMessage = reportPWDError?["message"] as? String + $0.reportPWDResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let reportShellStateResponse = try self.sendV2Request( + method: "surface.report_shell_state", + params: [ + "workspace_id": "workspace:0", + "surface_id": UUID().uuidString, + "state": "busy", + ], + id: 5, + to: clientFD, + timeout: 1.0 + ) + let reportShellStateError = reportShellStateResponse["error"] as? [String: Any] + observation.withLock { + $0.reportShellStateResponseOK = reportShellStateResponse["ok"] as? Bool + $0.reportShellStateErrorCode = reportShellStateError?["code"] as? String + $0.reportShellStateErrorMessage = reportShellStateError?["message"] as? String + $0.reportShellStateResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let reportAgentStateResponse = try self.sendV2Request( + method: "surface.report_agent_state", + params: [ + "workspace_id": workspaceID, + "surface_id": "surface:0", + "state": "blocked", + "source": "hooks", + ], + id: 6, + to: clientFD, + timeout: 1.0 + ) + let reportAgentStateError = reportAgentStateResponse["error"] as? [String: Any] + observation.withLock { + $0.reportAgentStateResponseOK = reportAgentStateResponse["ok"] as? Bool + $0.reportAgentStateErrorCode = reportAgentStateError?["code"] as? String + $0.reportAgentStateErrorMessage = reportAgentStateError?["message"] as? String + $0.reportAgentStateResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let clearAgentStateResponse = try self.sendV2Request( + method: "surface.clear_agent_state", + params: [ + "workspace_id": "workspace:0", + "surface_id": UUID().uuidString, + ], + id: 7, + to: clientFD, + timeout: 1.0 + ) + let clearAgentStateError = clearAgentStateResponse["error"] as? [String: Any] + observation.withLock { + $0.clearAgentStateResponseOK = clearAgentStateResponse["ok"] as? Bool + $0.clearAgentStateErrorCode = clearAgentStateError?["code"] as? String + $0.clearAgentStateErrorMessage = clearAgentStateError?["message"] as? String + $0.clearAgentStateResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let setAgentPIDResponse = try self.sendV2Request( + method: "workspace.set_agent_pid", + params: [ + "workspace_id": "workspace:0", + "key": "cache-only-agent", + "pid": 42, + ], + id: 8, + to: clientFD, + timeout: 1.0 + ) + let setAgentPIDError = setAgentPIDResponse["error"] as? [String: Any] + observation.withLock { + $0.setAgentPIDResponseOK = setAgentPIDResponse["ok"] as? Bool + $0.setAgentPIDErrorCode = setAgentPIDError?["code"] as? String + $0.setAgentPIDErrorMessage = setAgentPIDError?["message"] as? String + $0.setAgentPIDResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let clearAgentPIDResponse = try self.sendV2Request( + method: "workspace.clear_agent_pid", + params: [ + "workspace_id": "workspace:0", + "key": "cache-only-agent", + ], + id: 9, + to: clientFD, + timeout: 1.0 + ) + let clearAgentPIDError = clearAgentPIDResponse["error"] as? [String: Any] + observation.withLock { + $0.clearAgentPIDResponseOK = clearAgentPIDResponse["ok"] as? Bool + $0.clearAgentPIDErrorCode = clearAgentPIDError?["code"] as? String + $0.clearAgentPIDErrorMessage = clearAgentPIDError?["message"] as? String + $0.clearAgentPIDResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let setStatusResponse = try self.sendV2Request( + method: "workspace.set_status", + params: [ + "workspace_id": "workspace:0", + "key": "cache-only-status", + "value": "running", + ], + id: 10, + to: clientFD, + timeout: 1.0 + ) + let setStatusError = setStatusResponse["error"] as? [String: Any] + observation.withLock { + $0.setStatusResponseOK = setStatusResponse["ok"] as? Bool + $0.setStatusErrorCode = setStatusError?["code"] as? String + $0.setStatusErrorMessage = setStatusError?["message"] as? String + $0.setStatusResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + + let clearStatusResponse = try self.sendV2Request( + method: "workspace.clear_status", + params: [ + "workspace_id": "workspace:0", + "key": "cache-only-status", + ], + id: 11, + to: clientFD, + timeout: 1.0 + ) + let clearStatusError = clearStatusResponse["error"] as? [String: Any] + observation.withLock { + $0.clearStatusResponseOK = clearStatusResponse["ok"] as? Bool + $0.clearStatusErrorCode = clearStatusError?["code"] as? String + $0.clearStatusErrorMessage = clearStatusError?["message"] as? String + $0.clearStatusResponseWhileBlocked = !mainQueueBlockReturned.withLock { $0 } + } + } catch { + observation.withLock { + $0.errorDescription = String(describing: error) + } + } + } + + await fulfillment(of: [clientFinished], timeout: 5.0) + + let responseObservation = observation.withLock { $0 } + XCTAssertNil(responseObservation.errorDescription) + XCTAssertEqual(responseObservation.reportTTYResponseOK, false) + XCTAssertEqual(responseObservation.reportTTYErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.reportTTYErrorMessage?.contains("surface_id") == true, + "The validation error must identify the unknown surface_id" + ) + XCTAssertTrue( + responseObservation.reportTTYResponseWhileBlocked, + "surface.report_tty must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.portsKickResponseOK, false) + XCTAssertEqual(responseObservation.portsKickErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.portsKickErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.portsKickResponseWhileBlocked, + "surface.ports_kick must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.reportPWDResponseOK, false) + XCTAssertEqual(responseObservation.reportPWDErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.reportPWDErrorMessage?.contains("surface_id") == true, + "The validation error must identify the unknown surface_id" + ) + XCTAssertTrue( + responseObservation.reportPWDResponseWhileBlocked, + "surface.report_pwd must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.reportShellStateResponseOK, false) + XCTAssertEqual(responseObservation.reportShellStateErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.reportShellStateErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.reportShellStateResponseWhileBlocked, + "surface.report_shell_state must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.reportAgentStateResponseOK, false) + XCTAssertEqual(responseObservation.reportAgentStateErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.reportAgentStateErrorMessage?.contains("surface_id") == true, + "The validation error must identify the unknown surface_id" + ) + XCTAssertTrue( + responseObservation.reportAgentStateResponseWhileBlocked, + "surface.report_agent_state must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.clearAgentStateResponseOK, false) + XCTAssertEqual(responseObservation.clearAgentStateErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.clearAgentStateErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.clearAgentStateResponseWhileBlocked, + "surface.clear_agent_state must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.setAgentPIDResponseOK, false) + XCTAssertEqual(responseObservation.setAgentPIDErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.setAgentPIDErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.setAgentPIDResponseWhileBlocked, + "workspace.set_agent_pid must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.clearAgentPIDResponseOK, false) + XCTAssertEqual(responseObservation.clearAgentPIDErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.clearAgentPIDErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.clearAgentPIDResponseWhileBlocked, + "workspace.clear_agent_pid must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.setStatusResponseOK, false) + XCTAssertEqual(responseObservation.setStatusErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.setStatusErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.setStatusResponseWhileBlocked, + "workspace.set_status must reject an unknown cached handle without waiting for main" + ) + XCTAssertEqual(responseObservation.clearStatusResponseOK, false) + XCTAssertEqual(responseObservation.clearStatusErrorCode, "invalid_params") + XCTAssertTrue( + responseObservation.clearStatusErrorMessage?.contains("workspace_id") == true, + "The validation error must identify the unknown workspace_id" + ) + XCTAssertTrue( + responseObservation.clearStatusResponseWhileBlocked, + "workspace.clear_status must reject an unknown cached handle without waiting for main" + ) + XCTAssertFalse( + mainQueueBlockTimedOut.withLock { $0 }, + "All ten responses must arrive before the bounded main-queue blocker times out" + ) + } + func testWorkspaceCloseRejectsPinnedWorkspace() async throws { let socketPath = makeSocketPath("close-pinned") let manager = TabManager() @@ -1104,6 +2839,66 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { XCTAssertEqual(workspace.remoteDaemonStatus.detail, exactDiagnostic) } + func testNewAgentRefreshInvalidatesResultsAlreadyValidatedForPublication() async { + let manager = TabManager() + let workspace = manager.addWorkspace(select: true, eagerLoadTerminal: false) + let workspaceId = workspace.id + let gate = AgentPortPublicationGate() + let staleNonemptyPublication = OSAllocatedUnfairLock(initialState: false) + let validationPaused = expectation(description: "old agent ports validate before the newer refresh") + let emptyPublication = expectation(description: "current empty agent ports publish") + let oldApplyCompleted = expectation(description: "old validated agent ports finish the apply phase") + let scanner = PortScanner( + observesAppVisibility: false, + agentScanOverride: { workspaceIds, agentPIDsByWorkspace in + guard !agentPIDsByWorkspace.isEmpty else { return [:] } + return Dictionary(uniqueKeysWithValues: workspaceIds.map { ($0, Set([5173])) }) + }, + agentResultsValidatedHook: { results in + guard results.contains(where: { !$0.1.isEmpty }) else { return } + validationPaused.fulfill() + await gate.pause() + }, + agentResultsApplyCompletedHook: { results in + guard results.contains(where: { $0.0 == workspaceId && !$0.1.isEmpty }) else { return } + oldApplyCompleted.fulfill() + } + ) + scanner.onAgentPortsUpdated = { publishedWorkspaceId, ports in + guard publishedWorkspaceId == workspace.id else { return } + if !ports.isEmpty { + staleNonemptyPublication.withLock { $0 = true } + } + if workspace.agentListeningPorts != ports { + workspace.agentListeningPorts = ports + workspace.recomputeListeningPorts() + } + if ports.isEmpty { + emptyPublication.fulfill() + } + } + + XCTAssertTrue(workspace.setSidebarAgentPID(key: "test-agent", pid: 42)) + workspace.agentListeningPorts = [4242] + workspace.recomputeListeningPorts() + XCTAssertEqual(workspace.agentListeningPorts, [4242]) + XCTAssertEqual(workspace.listeningPorts, [4242]) + + scanner.refreshAgentPorts(workspaceId: workspaceId, agentPIDs: [42]) + await fulfillment(of: [validationPaused], timeout: 2.0) + + workspace.resetSidebarContext(reason: "test-agent-port-reset", portScanner: scanner) + await fulfillment(of: [emptyPublication], timeout: 2.0) + + await gate.release() + await fulfillment(of: [oldApplyCompleted], timeout: 2.0) + + XCTAssertFalse(staleNonemptyPublication.withLock { $0 }) + XCTAssertTrue(workspace.agentPIDs.isEmpty) + XCTAssertTrue(workspace.agentListeningPorts.isEmpty) + XCTAssertTrue(workspace.listeningPorts.isEmpty) + } + private func waitForSocket(at path: String, timeout: TimeInterval = 5.0) throws { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in @@ -1270,6 +3065,138 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { return fd } + private nonisolated func connectPersistentClient(to socketPath: String) throws -> Int32 { + let fd = try connect(to: socketPath) + do { + try suppressSIGPIPE(on: fd) + } catch { + Darwin.close(fd) + throw error + } + return fd + } + + private nonisolated func suppressSIGPIPE(on fd: Int32) throws { + var enabled: Int32 = 1 + let result = withUnsafePointer(to: &enabled) { pointer in + Darwin.setsockopt( + fd, + SOL_SOCKET, + SO_NOSIGPIPE, + pointer, + socklen_t(MemoryLayout.size) + ) + } + guard result == 0 else { + throw posixError("setsockopt(SO_NOSIGPIPE)") + } + } + + private nonisolated func sendV2Ping( + to fd: Int32, + id: Int, + timeout: TimeInterval = 5.0 + ) throws -> [String: Any] { + try sendV2Request( + method: "system.ping", + params: [:], + id: id, + to: fd, + timeout: timeout + ) + } + + private nonisolated func sendV2Request( + method: String, + params: [String: Any], + id: Int, + to fd: Int32, + timeout: TimeInterval = 5.0 + ) throws -> [String: Any] { + let payload: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + ] + let data = try JSONSerialization.data(withJSONObject: payload) + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode JSON-RPC request" + ]) + } + try writeLine(line, to: fd) + + let responseLine = try readLine(from: fd, timeout: timeout) + let responseData = Data(responseLine.utf8) + return try XCTUnwrap( + try JSONSerialization.jsonObject(with: responseData) as? [String: Any], + "Expected JSON-RPC response object" + ) + } + + private nonisolated func isSuccessfulV2Ping(_ response: [String: Any]) -> Bool { + guard response["ok"] as? Bool == true, + let result = response["result"] as? [String: Any] + else { return false } + return result["pong"] as? Bool == true + } + + private nonisolated func isSuccessfulV2Authentication(_ response: [String: Any]) -> Bool { + guard response["ok"] as? Bool == true, + let result = response["result"] as? [String: Any] + else { return false } + return result["authenticated"] as? Bool == true + } + + private nonisolated func v2ErrorCode(_ response: [String: Any]) -> String? { + guard response["ok"] as? Bool == false, + let error = response["error"] as? [String: Any] + else { return nil } + 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 @@ -1295,7 +3222,19 @@ final class TerminalControllerSocketSecurityTests: XCTestCase { guard count >= 0 else { throw posixError("read") } - if count == 0 { break } + if count == 0 { + if data.isEmpty { + // The peer closed the connection before sending any bytes for this + // line (e.g. a revoked client after a password rotation). Surface this + // distinctly from a legitimate empty response so callers using `try?` + // (to tolerate revocation) don't trip an unconditional XCTUnwrap + // failure on the caller side when they parse this as JSON. + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ECONNRESET), userInfo: [ + NSLocalizedDescriptionKey: "Connection closed before a response line was received", + ]) + } + break + } if buffer[0] == 0x0A { break } data.append(buffer[0]) } diff --git a/programaTests/WorkspaceRemoteConnectionTests.swift b/programaTests/WorkspaceRemoteConnectionTests.swift index 8bbe5663..02e1ca64 100644 --- a/programaTests/WorkspaceRemoteConnectionTests.swift +++ b/programaTests/WorkspaceRemoteConnectionTests.swift @@ -1,4 +1,5 @@ import XCTest +import Bonsplit #if canImport(Programa_DEV) @testable import Programa_DEV @@ -825,6 +826,178 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { 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() diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index d9795c49..7a1a6b2b 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -3385,6 +3385,193 @@ final class WorkspacePanelGitBranchTests: XCTestCase { activeLease = nil } + func testBrowserElementRefSurvivesDetachAndAttachThenExpiresOnPermanentTeardown() throws { + let source = Workspace() + let destination = Workspace() + var transferredSurfaceId: UUID? + defer { + source.teardownAllPanels() + destination.teardownAllPanels() + if let transferredSurfaceId { + TerminalController.shared.v2BrowserPermanentlyRemoveSurfaceState(surfaceId: transferredSurfaceId) + } + } + + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let browserPanel = try XCTUnwrap( + source.newBrowserSplit( + from: sourcePanelId, + orientation: .horizontal, + focus: false + ) + ) + transferredSurfaceId = browserPanel.id + let ref: String + switch TerminalController.shared.v2BrowserAllocateElementRefs( + surfaceId: browserPanel.id, + selectors: ["#survives-workspace-transfer"] + ) { + case .allocated(let refs): + ref = try XCTUnwrap(refs.first) + case .resourceExhausted: + XCTFail("A new browser panel must accept its first element ref") + return + } + XCTAssertEqual( + TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: browserPanel.id), + "#survives-workspace-transfer" + ) + + let detached = try XCTUnwrap(source.detachSurface(panelId: browserPanel.id)) + XCTAssertNil(source.panels[browserPanel.id]) + XCTAssertEqual( + TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: browserPanel.id), + "#survives-workspace-transfer", + "Detaching for transfer must preserve browser automation state" + ) + + let destinationPane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + XCTAssertEqual( + destination.attachDetachedSurface(detached, inPane: destinationPane, focus: false), + browserPanel.id + ) + XCTAssertTrue(destination.panels[browserPanel.id] is BrowserPanel) + XCTAssertEqual( + TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: browserPanel.id), + "#survives-workspace-transfer", + "Attaching the same browser panel must preserve its existing ref identity" + ) + + destination.teardownAllPanels() + switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: browserPanel.id) { + case .err(let code, _, _): + XCTAssertEqual(code, "not_found", "Permanent workspace teardown must remove the transferred browser ref") + case .ok: + XCTFail("A permanently closed browser panel ref must not remain resolvable") + } + } + + func testDetachedBrowserResolutionRollsBackAfterInvalidPrimaryWithoutLosingState() throws { + let source = Workspace() + let destination = Workspace() + defer { + source.teardownAllPanels() + destination.teardownAllPanels() + } + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let browserPanel = try XCTUnwrap(source.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, focus: false)) + let sourceRollbackPane = try XCTUnwrap(source.bonsplitController.allPaneIds.first) + let transfer = try XCTUnwrap(source.detachSurface(panelId: browserPanel.id)) + let ref: String + switch TerminalController.shared.v2BrowserAllocateElementRefs(surfaceId: browserPanel.id, selectors: ["#rollback-ref"]) { + case .allocated(let refs): ref = try XCTUnwrap(refs.first) + case .resourceExhausted: return XCTFail("Expected a fresh browser ref") + } + + let result = transfer.resolve( + primary: Workspace.DetachedSurfaceAttachmentTarget( + workspace: destination, + paneId: PaneID(), + index: nil, + focus: false + ), + rollback: Workspace.DetachedSurfaceAttachmentTarget( + workspace: source, + paneId: sourceRollbackPane, + index: nil, + focus: false + ) + ) + + guard case .attachedRollback(let panelId) = result else { + return XCTFail("Expected invalid primary attachment to use the valid source rollback") + } + XCTAssertEqual(panelId, browserPanel.id) + XCTAssertTrue((source.panels[browserPanel.id] as? BrowserPanel) === browserPanel) + XCTAssertNil(destination.panels[browserPanel.id]) + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: browserPanel.id), "#rollback-ref") + } + + func testDetachedBrowserResolutionFinalizesWhenPrimaryAndRollbackAreInvalid() throws { + let source = Workspace() + let destination = Workspace() + defer { + source.teardownAllPanels() + destination.teardownAllPanels() + } + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let browserPanel = try XCTUnwrap(source.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, focus: false)) + let transfer = try XCTUnwrap(source.detachSurface(panelId: browserPanel.id)) + let ref: String + switch TerminalController.shared.v2BrowserAllocateElementRefs(surfaceId: browserPanel.id, selectors: ["#finalized-ref"]) { + case .allocated(let refs): ref = try XCTUnwrap(refs.first) + case .resourceExhausted: return XCTFail("Expected a fresh browser ref") + } + + 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("A transfer with no valid attachment owner must finalize") + } + XCTAssertNil(source.panels[browserPanel.id]) + XCTAssertNil(destination.panels[browserPanel.id]) + XCTAssertNil(browserPanel.webView.navigationDelegate, "Finalization must close and disable the detached browser panel") + switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: browserPanel.id) { + case .err(let code, _, _): XCTAssertEqual(code, "not_found") + case .ok: XCTFail("Finalization must permanently remove browser ref state") + } + transfer.finalizePermanently() + XCTAssertNil(browserPanel.webView.navigationDelegate, "Repeated finalization must remain idempotent") + } + + func testAttachedTransferIgnoresStaleFinalizeAndNextDetachHasIndependentOwnership() throws { + let source = Workspace() + let destination = Workspace() + defer { + source.teardownAllPanels() + destination.teardownAllPanels() + } + let sourcePanelId = try XCTUnwrap(source.focusedPanelId) + let browserPanel = try XCTUnwrap(source.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, focus: false)) + let transfer = try XCTUnwrap(source.detachSurface(panelId: browserPanel.id)) + let destinationPane = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + let ref: String + switch TerminalController.shared.v2BrowserAllocateElementRefs(surfaceId: browserPanel.id, selectors: ["#attached-ref"]) { + case .allocated(let refs): ref = try XCTUnwrap(refs.first) + case .resourceExhausted: return XCTFail("Expected a fresh browser ref") + } + + let result = transfer.resolve( + primary: Workspace.DetachedSurfaceAttachmentTarget( + workspace: destination, + paneId: destinationPane, + index: nil, + focus: false + ), + rollback: nil + ) + guard case .attachedPrimary(let panelId) = result else { + return XCTFail("Expected the valid primary destination to own the panel") + } + XCTAssertEqual(panelId, browserPanel.id) + + transfer.finalizePermanently() + XCTAssertTrue((destination.panels[browserPanel.id] as? BrowserPanel) === browserPanel) + XCTAssertNotNil(browserPanel.webView.navigationDelegate) + XCTAssertEqual(TerminalController.shared.v2BrowserResolveSelector(ref, surfaceId: browserPanel.id), "#attached-ref") + + let nextTransfer = try XCTUnwrap(destination.detachSurface(panelId: browserPanel.id)) + XCTAssertFalse(nextTransfer === transfer, "A later detach must have a new pending lifecycle owner") + nextTransfer.finalizePermanently() + switch TerminalController.shared.v2BrowserSelectorResolutionError(ref, surfaceId: browserPanel.id) { + case .err(let code, _, _): XCTAssertEqual(code, "not_found") + case .ok: XCTFail("Finalizing the new pending transfer must remove its browser ref") + } + } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { let workspace = Workspace() guard let originalFocusedPanelId = workspace.focusedPanelId else { diff --git a/programaUITests/MultiWindowNotificationsUITests.swift b/programaUITests/MultiWindowNotificationsUITests.swift index 610e9517..b1fb659d 100644 --- a/programaUITests/MultiWindowNotificationsUITests.swift +++ b/programaUITests/MultiWindowNotificationsUITests.swift @@ -6,6 +6,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { private var dataPath = "" private var socketPath = "" private var launchTag = "" + private var launchedApplication: XCUIApplication? override func setUp() { super.setUp() @@ -18,13 +19,21 @@ final class MultiWindowNotificationsUITests: XCTestCase { } override func tearDown() { + launchedApplication?.terminate() + launchedApplication = nil try? FileManager.default.removeItem(atPath: dataPath) try? FileManager.default.removeItem(atPath: socketPath) super.tearDown() } + private func makeTrackedApplication() -> XCUIApplication { + let application = XCUIApplication() + launchedApplication = application + return application + } + func testNotificationsRouteToCorrectWindow() { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath app.launchEnvironment["PROGRAMA_TAG"] = launchTag @@ -111,7 +120,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testNotificationsPopoverCanCloseViaShortcutAndEscape() { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath app.launchEnvironment["PROGRAMA_TAG"] = launchTag @@ -148,7 +157,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testNotificationsPopoverJumpToLatestButtonShowsShortcut() { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath app.launchEnvironment["PROGRAMA_TAG"] = launchTag @@ -173,7 +182,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testEmptyNotificationsPopoverBlocksTerminalTyping() throws { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchArguments += ["-socketControlMode", "allowAll"] app.launchEnvironment["PROGRAMA_SOCKET_PATH"] = socketPath app.launchEnvironment["PROGRAMA_SOCKET_MODE"] = "allowAll" @@ -222,7 +231,7 @@ final class MultiWindowNotificationsUITests: XCTestCase { } func testNotifyCLIDoesNotStealFocusAcrossWindows() throws { - let app = XCUIApplication() + let app = makeTrackedApplication() app.launchArguments += ["-socketControlMode", "allowAll"] app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1" app.launchEnvironment["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath diff --git a/scripts/classify_ci_changes.sh b/scripts/classify_ci_changes.sh new file mode 100755 index 00000000..5a4e6e9f --- /dev/null +++ b/scripts/classify_ci_changes.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUN_APP_JOBS=false +RUN_REMOTE_DAEMON_JOBS=false +SAW_CHANGED_PATH=false + +while IFS= read -r path || [[ -n "$path" ]]; do + [[ -z "$path" ]] && continue + SAW_CHANGED_PATH=true + + case "$path" in + # Localization-only resource edits are scoped out per request + Resources/*.xcstrings|Resources/*/*.xcstrings|Resources/*.strings|Resources/*/*.strings) + continue + ;; + # Explicitly skip doc-only translation assets + Resources/*.lproj/*) + continue + ;; + # Images here are build inputs, not documentation assets. + Resources/**|Assets.xcassets/**) + RUN_APP_JOBS=true + ;; + # Documentation and prose + *.md|docs/*|plans/*|AGENTS.md|CHANGELOG.md|PROJECTS.md|TODO.md|README.md|LICENSE*|THIRD_PARTY_LICENSES.md|.editorconfig|.gitattributes|.gitignore|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.svg) + continue + ;; + # Repository metadata / workflow-only edits are not app/runtime changes + .github/*) + continue + ;; + daemon/**) + RUN_REMOTE_DAEMON_JOBS=true + ;; + *) + RUN_APP_JOBS=true + ;; + esac +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/create-dmg/bun.lock b/scripts/create-dmg/bun.lock new file mode 100644 index 00000000..3b99e10b --- /dev/null +++ b/scripts/create-dmg/bun.lock @@ -0,0 +1,215 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "programa-create-dmg-toolchain", + "dependencies": { + "create-dmg": "8.0.0", + }, + }, + }, + "packages": { + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], + + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "appdmg": ["appdmg@0.6.6", "", { "dependencies": { "async": "^1.4.2", "ds-store": "^0.1.5", "execa": "^1.0.0", "fs-temp": "^1.0.0", "fs-xattr": "^0.3.0", "image-size": "^0.7.4", "is-my-json-valid": "^2.20.0", "minimist": "^1.1.3", "parse-color": "^1.0.0", "path-exists": "^4.0.0", "repeat-string": "^1.5.4" }, "os": "darwin", "bin": { "appdmg": "bin/appdmg.js" } }, "sha512-GRmFKlCG+PWbcYF4LUNonTYmy0GjguDy6Jh9WP8mpd0T6j80XIJyXBiWlD0U+MLNhqV9Nhx49Gl9GpVToulpLg=="], + + "async": ["async@1.5.2", "", {}, "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w=="], + + "base32-encode": ["base32-encode@1.2.0", "", { "dependencies": { "to-data-view": "^1.1.0" } }, "sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bplist-creator": ["bplist-creator@0.0.8", "", { "dependencies": { "stream-buffers": "~2.2.0" } }, "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + + "color-convert": ["color-convert@0.5.3", "", {}, "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling=="], + + "create-dmg": ["create-dmg@8.0.0", "", { "dependencies": { "appdmg": "^0.6.6", "execa": "^9.6.1", "icns-lib": "^1.0.1", "meow": "^14.0.0", "ora": "^9.1.0", "plist": "^3.1.0", "tempy": "^3.1.1" }, "bin": { "create-dmg": "cli.js" } }, "sha512-S6kWzN7dJZsybEnUiz94V8cpn2mm++EUEs4iarohlApAmFDgr+NwD0FQ0DnbdZmbfx0AM22OyyV9U/xxKxs/WQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crypto-random-string": ["crypto-random-string@4.0.0", "", { "dependencies": { "type-fest": "^1.0.1" } }, "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA=="], + + "ds-store": ["ds-store@0.1.6", "", { "dependencies": { "bplist-creator": "~0.0.3", "macos-alias": "~0.2.5", "tn1150": "^0.1.0" } }, "sha512-kY21M6Lz+76OS3bnCzjdsJSF7LBpLYGCVfavW8TgQD2XkcqIZ86W0y9qUDZu6fp7SIZzqosMDW2zi7zVFfv4hw=="], + + "encode-utf8": ["encode-utf8@1.0.3", "", {}, "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "fmix": ["fmix@0.1.0", "", { "dependencies": { "imul": "^1.0.0" } }, "sha512-Y6hyofImk9JdzU8k5INtTXX1cu8LDlePWDFU5sftm9H+zKCr5SGrVjdhkvsim646cw5zD0nADj8oHyXMZmCZ9w=="], + + "fs-temp": ["fs-temp@1.2.1", "", { "dependencies": { "random-path": "^0.1.0" } }, "sha512-okTwLB7/Qsq82G6iN5zZJFsOfZtx2/pqrA7Hk/9fvy+c+eJS9CvgGXT2uNxwnI14BDY9L/jQPkaBgSvlKfSW9w=="], + + "fs-xattr": ["fs-xattr@0.3.1", "", { "os": "!win32" }, "sha512-UVqkrEW0GfDabw4C3HOrFlxKfx0eeigfRne69FxSBdHIP8Qt5Sq6Pu3RM9KmMlkygtC4pPKkj5CiPO5USnj2GA=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "generate-object-property": ["generate-object-property@1.2.0", "", { "dependencies": { "is-property": "^1.0.0" } }, "sha512-TuOwZWgJ2VAMEGJvAyPWvpqxSANF0LDpmyHauMjFYzaACvn+QTT/AZomvPCzVBV7yDN3OmwHQ5OvHaeLKre3JQ=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "icns-lib": ["icns-lib@1.0.1", "", {}, "sha512-J7+RDRQApG/vChY5TP043NitBcNC7QMn1kOgGvlAkyrK65hozAaSwTNsTZ2HJh+br9e1NlzpBreAOpk4YuhOJA=="], + + "image-size": ["image-size@0.7.5", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g=="], + + "imul": ["imul@1.0.1", "", {}, "sha512-WFAgfwPLAjU66EKt6vRdTlKj4nAgIDQzh29JonLa4Bqtl6D8JrIMvWjCnx7xEjVNmP3U0fM5o8ZObk7d0f62bA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-my-ip-valid": ["is-my-ip-valid@1.0.1", "", {}, "sha512-jxc8cBcOWbNK2i2aTkCZP6i7wkHF1bqKFrwEHuN5Jtg5BSaZHUZQ/JTOJwoV41YvHnOaRyWWh72T/KvfNz9DJg=="], + + "is-my-json-valid": ["is-my-json-valid@2.20.6", "", { "dependencies": { "generate-function": "^2.0.0", "generate-object-property": "^1.1.0", "is-my-ip-valid": "^1.0.0", "jsonpointer": "^5.0.0", "xtend": "^4.0.0" } }, "sha512-1JQwulVNjx8UqkPE/bqDaxtH4PXCe/2VRh/y3p99heOV87HG4Id5/VfDswd+YiAfHcRTfDlWgISycnHuhZq1aw=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + + "macos-alias": ["macos-alias@0.2.12", "", { "dependencies": { "nan": "^2.4.0" }, "os": "darwin" }, "sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw=="], + + "meow": ["meow@14.1.0", "", {}, "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "murmur-32": ["murmur-32@0.2.0", "", { "dependencies": { "encode-utf8": "^1.0.3", "fmix": "^0.1.0", "imul": "^1.0.0" } }, "sha512-ZkcWZudylwF+ir3Ld1n7gL6bI2mQAzXvSobPwVtu8aYi2sbXeipeSkdcanRLzIofLcM5F53lGaKm2dk7orBi7Q=="], + + "nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="], + + "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], + + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "parse-color": ["parse-color@1.0.0", "", { "dependencies": { "color-convert": "~0.5.0" } }, "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "plist": ["plist@3.1.1", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "random-path": ["random-path@0.1.2", "", { "dependencies": { "base32-encode": "^0.1.0 || ^1.0.0", "murmur-32": "^0.1.0 || ^0.2.0" } }, "sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw=="], + + "repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], + + "stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="], + + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "temp-dir": ["temp-dir@3.0.0", "", {}, "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw=="], + + "tempy": ["tempy@3.2.0", "", { "dependencies": { "is-stream": "^3.0.0", "temp-dir": "^3.0.0", "type-fest": "^2.12.2", "unique-string": "^3.0.0" } }, "sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ=="], + + "tn1150": ["tn1150@0.1.0", "", { "dependencies": { "unorm": "^1.4.1" } }, "sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w=="], + + "to-data-view": ["to-data-view@1.1.0", "", {}, "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ=="], + + "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "unique-string": ["unique-string@3.0.0", "", { "dependencies": { "crypto-random-string": "^4.0.0" } }, "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ=="], + + "unorm": ["unorm@1.6.0", "", {}, "sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "appdmg/execa": ["execa@1.0.0", "", { "dependencies": { "cross-spawn": "^6.0.0", "get-stream": "^4.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } }, "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA=="], + + "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + + "appdmg/execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], + + "appdmg/execa/get-stream": ["get-stream@4.1.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w=="], + + "appdmg/execa/is-stream": ["is-stream@1.1.0", "", {}, "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ=="], + + "appdmg/execa/npm-run-path": ["npm-run-path@2.0.2", "", { "dependencies": { "path-key": "^2.0.0" } }, "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw=="], + + "appdmg/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "appdmg/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + + "appdmg/execa/cross-spawn/shebang-command": ["shebang-command@1.2.0", "", { "dependencies": { "shebang-regex": "^1.0.0" } }, "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg=="], + + "appdmg/execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + + "appdmg/execa/npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + + "appdmg/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], + } +} diff --git a/scripts/create-dmg/package.json b/scripts/create-dmg/package.json new file mode 100644 index 00000000..88a77a60 --- /dev/null +++ b/scripts/create-dmg/package.json @@ -0,0 +1,7 @@ +{ + "name": "programa-create-dmg-toolchain", + "private": true, + "dependencies": { + "create-dmg": "8.0.0" + } +} diff --git a/scripts/ghostty_cache_revision.sh b/scripts/ghostty_cache_revision.sh new file mode 100755 index 00000000..0afb0bdc --- /dev/null +++ b/scripts/ghostty_cache_revision.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_PATH="${1:-$SCRIPT_DIR/../ghostty}" + +if [ ! -e "$REPO_PATH" ]; then + echo "ghostty cache revision: path does not exist: $REPO_PATH" >&2 + exit 1 +fi + +if [ ! -d "$REPO_PATH" ]; then + echo "ghostty cache revision: path is not a directory: $REPO_PATH" >&2 + exit 1 +fi + +if ! CANONICAL_REPO_PATH="$(cd "$REPO_PATH" 2>/dev/null && pwd -P)"; then + echo "ghostty cache revision: cannot resolve path: $REPO_PATH" >&2 + exit 1 +fi + +if ! INSIDE_WORK_TREE="$(git -C "$REPO_PATH" rev-parse --is-inside-work-tree 2>/dev/null)"; then + echo "ghostty cache revision: path is not a Git directory: $REPO_PATH" >&2 + exit 1 +fi + +if [ "$INSIDE_WORK_TREE" != "true" ]; then + echo "ghostty cache revision: path is not a Git worktree: $REPO_PATH" >&2 + exit 1 +fi + +if ! TOP_LEVEL="$(git -C "$REPO_PATH" rev-parse --show-toplevel 2>/dev/null)" \ + || ! CANONICAL_TOP_LEVEL="$(cd "$TOP_LEVEL" 2>/dev/null && pwd -P)"; then + echo "ghostty cache revision: cannot resolve Git worktree root: $REPO_PATH" >&2 + exit 1 +fi + +if [ "$CANONICAL_REPO_PATH" != "$CANONICAL_TOP_LEVEL" ]; then + echo "ghostty cache revision: path is not the Git worktree root: $REPO_PATH" >&2 + exit 1 +fi + +if ! REVISION="$(git -C "$REPO_PATH" rev-parse --verify 'HEAD^{commit}' 2>/dev/null)"; then + echo "ghostty cache revision: HEAD is not a commit: $REPO_PATH" >&2 + exit 1 +fi + +if [[ ! "$REVISION" =~ ^[0-9a-f]{40}$ ]]; then + echo "ghostty cache revision: HEAD is not a 40-character lowercase SHA-1: $REPO_PATH" >&2 + exit 1 +fi + +printf '%s\n' "$REVISION" diff --git a/scripts/install-create-dmg.sh b/scripts/install-create-dmg.sh index 5c071e26..588634ab 100755 --- a/scripts/install-create-dmg.sh +++ b/scripts/install-create-dmg.sh @@ -2,11 +2,41 @@ set -euo pipefail VERSION="${CREATE_DMG_VERSION:-}" -NPM_COMMAND="${PROGRAMA_NPM_COMMAND:-npm}" +BUN_COMMAND="${PROGRAMA_BUN_COMMAND:-bun}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="${SCRIPT_DIR}/create-dmg" if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then echo "CREATE_DMG_VERSION must be an explicit version (for example, 8.0.0)" >&2 exit 1 fi -"$NPM_COMMAND" install --global "create-dmg@$VERSION" +node - "${INSTALL_DIR}/package.json" "${INSTALL_DIR}/bun.lock" "${VERSION}" <<'NODE' +"use strict"; +const fs = require("node:fs"); +const [packagePath, lockPath, expectedVersion] = process.argv.slice(2); +const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); +if (packageJson.dependencies?.["create-dmg"] !== expectedVersion) { + throw new Error(`CREATE_DMG_VERSION ${expectedVersion} does not match the locked package version`); +} +const lock = fs.readFileSync(lockPath, "utf8"); +if (!lock.includes(`create-dmg@${expectedVersion}`) || !/sha512-[A-Za-z0-9+/]+={0,2}/.test(lock)) { + throw new Error("create-dmg bun.lock is missing its exact version or integrity metadata"); +} +NODE + +command -v "${BUN_COMMAND}" >/dev/null 2>&1 || { + echo "Bun command is unavailable: ${BUN_COMMAND}" >&2 + exit 1 +} + +"${BUN_COMMAND}" install \ + --cwd "${INSTALL_DIR}" \ + --frozen-lockfile \ + --ignore-scripts + +LOCAL_BIN="${INSTALL_DIR}/node_modules/.bin" +if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "${LOCAL_BIN}" >> "${GITHUB_PATH}" +fi +echo "create-dmg local bin: ${LOCAL_BIN}" diff --git a/scripts/milestone_payload.js b/scripts/milestone_payload.js new file mode 100644 index 00000000..3597997e --- /dev/null +++ b/scripts/milestone_payload.js @@ -0,0 +1,192 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { validateReleasePayloadReferences } = require("./rolling_release_state"); + +const MANIFEST_NAME = "programa-milestone-payload.json"; +const CANONICAL_BUILD = /^[1-9][0-9]*$/; +const SHA256 = /^[0-9a-f]{64}$/; +const MAX_SAFE_SIZE = BigInt(Number.MAX_SAFE_INTEGER); + +function assertBuild(build) { + if (typeof build !== "string" || !CANONICAL_BUILD.test(build)) { + throw new TypeError("milestone build must be a canonical positive decimal string"); + } +} + +function payloadNames(build) { + assertBuild(build); + return [ + "appcast.xml", + `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`, + ]; +} + +function assertExactFields(value, expected, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const keys = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index])) { + throw new TypeError(`${label} fields must be exactly: ${expected.join(", ")}`); + } +} + +function resolveDirectory(directory) { + if (typeof directory !== "string" || directory === "") { + throw new TypeError("milestone payload directory is required"); + } + const resolved = path.resolve(directory); + const stat = fs.lstatSync(resolved); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new TypeError("milestone payload path must be a real directory"); + } + return resolved; +} + +function assertExactDirectory(directory, expectedNames) { + const actual = fs.readdirSync(directory).sort(); + const expected = [...expectedNames].sort(); + if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) { + const extra = actual.filter((name) => !expected.includes(name)); + const missing = expected.filter((name) => !actual.includes(name)); + throw new TypeError( + `milestone payload directory must contain exactly the expected files; ` + + `missing: ${missing.join(", ") || "none"}; unexpected: ${extra.join(", ") || "none"}`, + ); + } +} + +function inspectFile(directory, name) { + if (path.basename(name) !== name || name === "." || name === "..") { + throw new TypeError(`milestone payload name is unsafe: ${name}`); + } + const filePath = path.join(directory, name); + const stat = fs.lstatSync(filePath, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new TypeError(`milestone payload must be a regular file: ${name}`); + } + if (stat.size <= 0n) throw new TypeError(`milestone payload must be nonempty: ${name}`); + if (stat.size > MAX_SAFE_SIZE) { + throw new TypeError(`milestone payload size exceeds the safe integer range: ${name}`); + } + const bytes = fs.readFileSync(filePath); + return { + name, + size: Number(stat.size), + sha256: crypto.createHash("sha256").update(bytes).digest("hex"), + }; +} + +function createMilestoneManifest({ directory, build }) { + const names = payloadNames(build); + const resolved = resolveDirectory(directory); + assertExactDirectory(resolved, names); + return { + schemaVersion: 1, + build, + files: names.map((name) => inspectFile(resolved, name)), + }; +} + +function writeMilestoneManifest({ directory, build }) { + const resolved = resolveDirectory(directory); + const manifest = createMilestoneManifest({ directory: resolved, build }); + fs.writeFileSync( + path.join(resolved, MANIFEST_NAME), + `${JSON.stringify(manifest)}\n`, + { mode: 0o600 }, + ); + return manifest; +} + +function verifyMilestonePayload({ directory, build }) { + const names = payloadNames(build); + const resolved = resolveDirectory(directory); + assertExactDirectory(resolved, [...names, MANIFEST_NAME]); + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(path.join(resolved, MANIFEST_NAME), "utf8")); + } catch (error) { + throw new TypeError("milestone payload manifest is not valid JSON", { cause: error }); + } + assertExactFields(manifest, ["schemaVersion", "build", "files"], "milestone manifest"); + if (manifest.schemaVersion !== 1) throw new TypeError("milestone manifest schemaVersion must be 1"); + if (manifest.build !== build) throw new TypeError("milestone manifest build does not match"); + if (!Array.isArray(manifest.files) || manifest.files.length !== names.length) { + throw new TypeError("milestone manifest must contain exactly ten files"); + } + + const normalizedFiles = manifest.files.map((file, index) => { + assertExactFields(file, ["name", "size", "sha256"], `milestone manifest file ${index}`); + if (file.name !== names[index] || path.basename(file.name) !== file.name) { + throw new TypeError(`milestone manifest file ${index} has an unsafe or unexpected name`); + } + if (!Number.isSafeInteger(file.size) || file.size <= 0) { + throw new TypeError(`milestone manifest file ${file.name} has an invalid size`); + } + if (typeof file.sha256 !== "string" || !SHA256.test(file.sha256)) { + throw new TypeError(`milestone manifest file ${file.name} has an invalid sha256`); + } + const observed = inspectFile(resolved, file.name); + if (observed.size !== file.size || observed.sha256 !== file.sha256) { + throw new TypeError(`milestone payload bytes do not match manifest: ${file.name}`); + } + return observed; + }); + + return { schemaVersion: 1, build, files: normalizedFiles }; +} + +function validateMilestonePayloadReferences({ directory, build, repository, tag, version }) { + const payload = verifyMilestonePayload({ directory, build }); + if (typeof version !== "string" || tag !== `v${version}`) { + throw new TypeError("milestone version must exactly match its destination tag"); + } + const assets = payload.files.map((file) => ({ + ...file, + role: + file.name === "appcast.xml" + ? "appcast" + : file.name === "programa-macos.dmg" + ? "stable-alias" + : "immutable", + })); + 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: { + schemaVersion: 1, + sealed: true, + targetSha: "0".repeat(40), + version, + build, + assets, + }, + }); +} + +module.exports = { + createMilestoneManifest, + validateMilestonePayloadReferences, + verifyMilestonePayload, + writeMilestoneManifest, +}; diff --git a/scripts/milestone_payload.test.js b/scripts/milestone_payload.test.js new file mode 100644 index 00000000..f85de705 --- /dev/null +++ b/scripts/milestone_payload.test.js @@ -0,0 +1,150 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +// Public contract for scripts/milestone_payload.js: +// +// createMilestoneManifest({ directory, build }) -> manifest +// writeMilestoneManifest({ directory, build }) -> manifest +// verifyMilestonePayload({ directory, build }) -> manifest +// +// The manifest is `{ schemaVersion: 1, build, files }`, where `files` is the +// deterministic list of exactly ten milestone assets as +// `{ name, size, sha256 }`. The written filename is +// `programa-milestone-payload.json`. Verification requires exactly those ten +// payloads plus that manifest, validates its exact JSON schema, and hashes the +// downloaded bytes rather than trusting metadata. +const { + createMilestoneManifest, + verifyMilestonePayload, + writeMilestoneManifest, +} = require("./milestone_payload"); + +const BUILD = "900719925474099312345678901234567890"; +const MANIFEST_NAME = "programa-milestone-payload.json"; + +function expectedNames(build = BUILD) { + return [ + "appcast.xml", + `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`, + ]; +} + +function fixture(t, build = BUILD) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "programa-milestone-payload-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + for (const [index, name] of expectedNames(build).entries()) { + fs.writeFileSync(path.join(directory, name), `payload-${index}-${name}\n`); + } + return directory; +} + +function sha256(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} + +test("manifest creation records the exact ten milestone files and their bytes", (t) => { + const directory = fixture(t); + const manifest = createMilestoneManifest({ directory, build: BUILD }); + + assert.deepEqual(Object.keys(manifest).sort(), ["build", "files", "schemaVersion"]); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.build, BUILD); + assert.deepEqual(manifest.files.map((file) => file.name), expectedNames()); + for (const file of manifest.files) { + const bytes = fs.readFileSync(path.join(directory, file.name)); + assert.deepEqual(Object.keys(file).sort(), ["name", "sha256", "size"]); + assert.equal(file.size, bytes.length); + assert.equal(file.sha256, sha256(bytes)); + } +}); + +test("a written manifest verifies an exact downloaded payload directory", (t) => { + const directory = fixture(t); + const written = writeMilestoneManifest({ directory, build: BUILD }); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(directory, MANIFEST_NAME), "utf8")), written); + assert.deepEqual(verifyMilestonePayload({ directory, build: BUILD }), written); +}); + +test("creation rejects incomplete, extra, empty, and wrong-build payload sets", async (t) => { + await t.test("missing", (t) => { + const directory = fixture(t); + fs.rmSync(path.join(directory, expectedNames()[0])); + assert.throws(() => createMilestoneManifest({ directory, build: BUILD }), /missing|exactly|payload/i); + }); + await t.test("extra", (t) => { + const directory = fixture(t); + fs.writeFileSync(path.join(directory, "unexpected.bin"), "extra"); + assert.throws(() => createMilestoneManifest({ directory, build: BUILD }), /extra|unexpected|exactly/i); + }); + await t.test("empty", (t) => { + const directory = fixture(t); + fs.writeFileSync(path.join(directory, expectedNames()[2]), ""); + assert.throws(() => createMilestoneManifest({ directory, build: BUILD }), /empty|size|positive/i); + }); + await t.test("wrong build", (t) => { + const directory = fixture(t); + assert.throws(() => createMilestoneManifest({ directory, build: "41" }), /build|missing|unexpected/i); + }); +}); + +test("verification rejects missing, extra, tampered, and wrong-build downloads", async (t) => { + await t.test("missing payload", (t) => { + const directory = fixture(t); + writeMilestoneManifest({ directory, build: BUILD }); + fs.rmSync(path.join(directory, expectedNames()[1])); + assert.throws(() => verifyMilestonePayload({ directory, build: BUILD }), /missing|payload/i); + }); + await t.test("extra payload", (t) => { + const directory = fixture(t); + writeMilestoneManifest({ directory, build: BUILD }); + fs.writeFileSync(path.join(directory, "extra"), "extra"); + assert.throws(() => verifyMilestonePayload({ directory, build: BUILD }), /extra|unexpected|exactly/i); + }); + await t.test("tampered bytes", (t) => { + const directory = fixture(t); + writeMilestoneManifest({ directory, build: BUILD }); + fs.appendFileSync(path.join(directory, expectedNames()[3]), "tampered"); + assert.throws(() => verifyMilestonePayload({ directory, build: BUILD }), /sha|hash|size|tamper|bytes/i); + }); + await t.test("wrong requested build", (t) => { + const directory = fixture(t); + writeMilestoneManifest({ directory, build: BUILD }); + assert.throws(() => verifyMilestonePayload({ directory, build: "41" }), /build|manifest/i); + }); +}); + +test("verification rejects unsafe names and unknown manifest fields", async (t) => { + for (const [name, mutate] of [ + ["unsafe path", (manifest) => { manifest.files[0].name = "../appcast.xml"; }], + ["unknown root field", (manifest) => { manifest.unexpected = true; }], + ["unknown file field", (manifest) => { manifest.files[0].unexpected = true; }], + ["wrong manifest build", (manifest) => { manifest.build = "41"; }], + ]) { + await t.test(name, (t) => { + const directory = fixture(t); + writeMilestoneManifest({ directory, build: BUILD }); + const manifestPath = path.join(directory, MANIFEST_NAME); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + mutate(manifest); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + assert.throws( + () => verifyMilestonePayload({ directory, build: BUILD }), + /field|schema|name|path|unsafe|build|manifest/i, + ); + }); + } +}); diff --git a/scripts/publish_milestone_release.sh b/scripts/publish_milestone_release.sh new file mode 100755 index 00000000..f704797c --- /dev/null +++ b/scripts/publish_milestone_release.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PAYLOAD_MODULE="${SCRIPT_DIR}/milestone_payload.js" +GH_COMMAND="${GH_BIN:-gh}" +REPOSITORY="${GITHUB_REPOSITORY:-}" +TAG="" +TARGET_SHA="" +BUILD="" +PAYLOAD_DIR="" +TEMP_DIR="" + +fail() { + echo "publish_milestone_release.sh: $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Usage: publish_milestone_release.sh \ + --tag \ + --target-sha <40-lowercase-hex> \ + --build \ + --payload-dir +EOF +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi + exit "${status}" +} + +file_size() { + stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1" +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + fail "neither shasum nor sha256sum is available" + fi +} + +while (($#)); do + case "$1" in + --tag|--target-sha|--build|--payload-dir) + (($# >= 2)) || { usage; fail "$1 requires a value"; } + case "$1" in + --tag) [[ -z "${TAG}" ]] || fail "--tag may be supplied only once"; TAG="$2" ;; + --target-sha) [[ -z "${TARGET_SHA}" ]] || fail "--target-sha may be supplied only once"; TARGET_SHA="$2" ;; + --build) [[ -z "${BUILD}" ]] || fail "--build may be supplied only once"; BUILD="$2" ;; + --payload-dir) [[ -z "${PAYLOAD_DIR}" ]] || fail "--payload-dir may be supplied only once"; PAYLOAD_DIR="$2" ;; + esac + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + fail "unknown argument: $1" + ;; + esac +done + +[[ "${REPOSITORY}" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || \ + fail "GITHUB_REPOSITORY must be owner/repository" +[[ "${TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \ + fail "--tag must be canonical vMAJOR.MINOR.PATCH" +[[ "${TARGET_SHA}" =~ ^[0-9a-f]{40}$ ]] || \ + fail "--target-sha must be 40 lowercase hexadecimal characters" +[[ "${BUILD}" =~ ^[1-9][0-9]*$ ]] || \ + fail "--build must be a canonical positive decimal string" +[[ -n "${PAYLOAD_DIR}" ]] || fail "--payload-dir is required" +[[ -r "${PAYLOAD_MODULE}" ]] || fail "missing milestone payload module" +GH_COMMAND="$(command -v "${GH_COMMAND}")" || fail "GitHub CLI command is unavailable" +command -v node >/dev/null 2>&1 || fail "node is required" + +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-milestone-release.XXXXXX")" +trap cleanup EXIT INT TERM + +# Verify the complete local handoff before even reading remote release state. +EXPECTED_TSV="${TEMP_DIR}/expected.tsv" +node - "${PAYLOAD_MODULE}" "${PAYLOAD_DIR}" "${BUILD}" "${REPOSITORY}" "${TAG}" > "${EXPECTED_TSV}" <<'NODE' +"use strict"; +const [modulePath, directory, build, repository, tag] = process.argv.slice(2); +const { validateMilestonePayloadReferences, verifyMilestonePayload } = require(modulePath); +validateMilestonePayloadReferences({ directory, build, repository, tag, version: tag.slice(1) }); +for (const file of verifyMilestonePayload({ directory, build }).files) { + process.stdout.write(`${file.name}\t${file.size}\t${file.sha256}\n`); +} +NODE + +declare -A EXPECTED_SIZE=() +declare -A EXPECTED_SHA=() +while IFS=$'\t' read -r name size sha extra; do + [[ -n "${name}" && -z "${extra:-}" ]] || fail "local milestone manifest produced invalid metadata" + EXPECTED_SIZE["${name}"]="${size}" + EXPECTED_SHA["${name}"]="${sha}" +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" +) +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" + +require_live_tag_target() { + local live_target + live_target="$("${GH_COMMAND}" api \ + "repos/${REPOSITORY}/git/ref/tags/${TAG}" \ + --jq .object.sha)" || fail "could not read live tag ref ${TAG}" + [[ "${live_target}" =~ ^[0-9a-f]{40}$ ]] || fail "live tag ref ${TAG} is not a commit SHA" + [[ "${live_target}" == "${TARGET_SHA}" ]] || \ + fail "live tag ref ${TAG} targets ${live_target}, expected ${TARGET_SHA}" +} + +validate_local_payload_references() { + node - "${PAYLOAD_MODULE}" "${PAYLOAD_DIR}" "${BUILD}" "${REPOSITORY}" "${TAG}" <<'NODE' +const [modulePath, directory, build, repository, tag] = process.argv.slice(2); +const { validateMilestonePayloadReferences } = require(modulePath); +validateMilestonePayloadReferences({ directory, build, repository, tag, version: tag.slice(1) }); +NODE +} + +release_value() { + local field="$1" + "${GH_COMMAND}" release view "${TAG}" \ + --repo "${REPOSITORY}" \ + --json "${field}" \ + --jq ".${field}" +} + +query_assets() { + local destination="$1" + "${GH_COMMAND}" release view "${TAG}" \ + --repo "${REPOSITORY}" \ + --json assets \ + --jq '.assets[] | [.id, .name, .state, (.size | tostring), (.digest // "-")] | @tsv' \ + > "${destination}" +} + +verify_release_metadata() { + local expected_draft="$1" expected_latest="$2" expected_immutable="$3" + local observed state_row state_tag state_draft state_latest + state_row="$(release_state_row)" || fail "milestone release state is missing or ambiguous" + IFS=$'\t' read -r state_tag state_draft state_latest <<< "${state_row}" + [[ "${state_tag}" == "${TAG}" ]] || fail "release state resolved to an unexpected tag" + [[ "${state_draft}" == "${expected_draft}" ]] || fail "release draft state is inconsistent" + if [[ "${expected_latest}" != "any" ]]; then + [[ "${state_latest}" == "${expected_latest}" ]] || fail "release latest state is inconsistent" + fi + observed="$(release_value isImmutable)" || fail "could not read release immutable state" + [[ "${observed}" == "${expected_immutable}" ]] || fail "release immutable state is inconsistent" + observed="$(release_value name)" || fail "could not read release title" + [[ "${observed}" == "${TAG}" ]] || fail "release title must equal ${TAG}" + observed="$(release_value body)" || fail "could not read release notes" + [[ -n "${observed}" ]] || fail "release notes must be nonempty" +} + +release_state_row() { + local states_file="${TEMP_DIR}/release-states.tsv" + "${GH_COMMAND}" release list \ + --repo "${REPOSITORY}" \ + --limit 1000 \ + --json tagName,isDraft,isLatest \ + --jq '.[] | [.tagName, .isDraft, .isLatest] | @tsv' > "${states_file}" || \ + fail "could not list milestone releases" + awk -F '\t' -v expected="${TAG}" ' + $1 == expected { row = $0; matches += 1 } + END { + if (matches == 1) print row + else if (matches > 1) exit 2 + else exit 1 + } + ' "${states_file}" +} + +verify_downloaded_asset() { + local name="$1" destination="$2" observed_size observed_sha + rm -f "${destination}/${name}" + "${GH_COMMAND}" release download "${TAG}" \ + --repo "${REPOSITORY}" \ + --pattern "${name}" \ + --dir "${destination}" || fail "could not download ${TAG}/${name}" + [[ -f "${destination}/${name}" ]] || fail "authenticated download omitted ${TAG}/${name}" + observed_size="$(file_size "${destination}/${name}")" + observed_sha="$(sha256_file "${destination}/${name}")" + [[ "${observed_size}" == "${EXPECTED_SIZE[${name}]}" ]] || \ + fail "release asset ${name} has unexpected size" + [[ "${observed_sha}" == "${EXPECTED_SHA[${name}]}" ]] || \ + fail "release asset ${name} has unexpected bytes" +} + +declare -A PRESENT=() +inspect_remote_assets() { + local metadata_file="$1" download_dir="$2" require_complete="${3:-false}" + local asset_id name state size digest extra + PRESENT=() + query_assets "${metadata_file}" + while IFS=$'\t' read -r asset_id name state size digest extra; do + [[ -n "${name}" ]] || continue + [[ -z "${extra:-}" ]] || fail "release asset metadata has unexpected fields" + [[ -n "${EXPECTED_SIZE[${name}]+x}" ]] || fail "release contains unexpected asset ${name}" + [[ -z "${PRESENT[${name}]+x}" ]] || fail "release contains duplicate asset metadata for ${name}" + PRESENT["${name}"]=1 + [[ -n "${asset_id}" && "${state}" == "uploaded" ]] || \ + fail "release asset ${name} is not completely uploaded" + [[ "${size}" == "${EXPECTED_SIZE[${name}]}" ]] || fail "release asset ${name} has conflicting size" + if [[ -n "${digest:-}" && "${digest}" != "-" && "${digest}" != "null" ]]; then + [[ "${digest}" == "sha256:${EXPECTED_SHA[${name}]}" ]] || \ + fail "release asset ${name} has conflicting digest" + fi + done < "${metadata_file}" + + if [[ "${require_complete}" == "true" && "${#PRESENT[@]}" -ne 10 ]]; then + fail "published milestone release has a partial asset set" + fi + + mkdir -p "${download_dir}" + for name in "${SAFE_ORDER[@]}"; do + [[ -n "${PRESENT[${name}]+x}" ]] || continue + verify_downloaded_asset "${name}" "${download_dir}" + done +} + +RELEASE_EXISTS=false +# The live immutable ref is authoritative for every path, including an +# otherwise-idempotent retry of an already-published release. +require_live_tag_target +if INITIAL_STATE="$(release_state_row)"; then + RELEASE_EXISTS=true +else + state_status=$? + [[ "${state_status}" -eq 1 ]] || fail "milestone release state is ambiguous" +fi + +if [[ "${RELEASE_EXISTS}" != "true" ]]; then + validate_local_payload_references + "${GH_COMMAND}" release create "${TAG}" \ + --repo "${REPOSITORY}" \ + --draft \ + --target "${TARGET_SHA}" \ + --title "${TAG}" \ + --generate-notes +fi + +CURRENT_STATE="$(release_state_row)" || fail "created milestone release state is missing or ambiguous" +IFS=$'\t' read -r current_tag IS_DRAFT IS_LATEST <<< "${CURRENT_STATE}" +[[ "${current_tag}" == "${TAG}" ]] || fail "release state resolved to an unexpected tag" +if [[ "${IS_DRAFT}" == "true" ]]; then + [[ "${IS_LATEST}" == "false" ]] || fail "draft milestone release must not be latest" + verify_release_metadata true false false +elif [[ "${IS_DRAFT}" == "false" ]]; then + verify_release_metadata false any true +else + fail "release draft state is invalid" +fi + +REQUIRE_COMPLETE=false +if [[ "${IS_DRAFT}" == "false" ]]; then + REQUIRE_COMPLETE=true +fi +inspect_remote_assets \ + "${TEMP_DIR}/existing-assets.tsv" \ + "${TEMP_DIR}/existing-downloads" \ + "${REQUIRE_COMPLETE}" + +if [[ "${IS_DRAFT}" == "false" ]]; then + echo "Milestone release ${TAG} is already published and verified." + exit 0 +fi + +# Every existing draft byte is authenticated above. Only absent assets may now be added. +require_live_tag_target +validate_local_payload_references +for name in "${SAFE_ORDER[@]}"; do + [[ -z "${PRESENT[${name}]+x}" ]] || continue + "${GH_COMMAND}" release upload "${TAG}" "${PAYLOAD_DIR}/${name}" --repo "${REPOSITORY}" +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" + +# The immutable tag is checked again after uploads and immediately before publication. +require_live_tag_target +validate_local_payload_references +EXPECTED_PUBLISHED_BODY="${TEMP_DIR}/expected-published-body.md" +"${GH_COMMAND}" release view "${TAG}" --repo "${REPOSITORY}" --json body --jq .body > "${EXPECTED_PUBLISHED_BODY}" || \ + fail "could not snapshot milestone release notes before publication" +[[ -s "${EXPECTED_PUBLISHED_BODY}" ]] || fail "milestone release notes must be nonempty before publication" +"${GH_COMMAND}" release edit "${TAG}" \ + --repo "${REPOSITORY}" \ + --title "${TAG}" \ + --draft=false \ + --latest +verify_release_metadata false true true +FINAL_PUBLISHED_BODY="${TEMP_DIR}/final-published-body.md" +"${GH_COMMAND}" release view "${TAG}" --repo "${REPOSITORY}" --json body --jq .body > "${FINAL_PUBLISHED_BODY}" || \ + fail "could not verify milestone release notes after publication" +cmp -s "${EXPECTED_PUBLISHED_BODY}" "${FINAL_PUBLISHED_BODY}" || fail "milestone release notes changed during publication" +inspect_remote_assets \ + "${TEMP_DIR}/final-assets.tsv" \ + "${TEMP_DIR}/final-downloads" \ + true +echo "Milestone release ${TAG} is published and verified." diff --git a/scripts/publish_release_candidate.sh b/scripts/publish_release_candidate.sh new file mode 100755 index 00000000..129209ab --- /dev/null +++ b/scripts/publish_release_candidate.sh @@ -0,0 +1,496 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly SEAL_NAME="programa-release-candidate.json" +readonly EXPECTED_ASSET_COUNT=10 + +die() { + echo "publish_release_candidate: $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +usage: publish_release_candidate.sh \ + --candidate-prefix \ + --destination-tag \ + --candidate-tag [-] \ + --target-sha <40-lowercase-hex> \ + --build \ + --version \ + --seal-output \ + [--prepare-only] \ + --asset-role = [--asset-role ...] +EOF +} + +file_size() { + stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1" +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + die "neither shasum nor sha256sum is available" + fi +} + +candidate_prefix="" +destination_tag="" +candidate_tag="" +target_sha="" +build="" +version="" +seal_output="" +prepare_only=false +asset_specs=() +seen_candidate_prefix=0 +seen_destination_tag=0 +seen_candidate_tag=0 +seen_target_sha=0 +seen_build=0 +seen_version=0 +seen_seal_output=0 + +while (($#)); do + case "$1" in + --prepare-only) + [[ "${prepare_only}" == "false" ]] || die "--prepare-only may be supplied only once" + prepare_only=true + shift + ;; + --candidate-prefix|--destination-tag|--candidate-tag|--target-sha|--build|--version|--seal-output|--asset-role) + (($# >= 2)) || { usage; die "$1 requires a value"; } + case "$1" in + --candidate-prefix) + ((seen_candidate_prefix == 0)) || die "--candidate-prefix may be supplied only once" + candidate_prefix="$2" + seen_candidate_prefix=1 + ;; + --destination-tag) + ((seen_destination_tag == 0)) || die "--destination-tag may be supplied only once" + destination_tag="$2" + seen_destination_tag=1 + ;; + --candidate-tag) + ((seen_candidate_tag == 0)) || die "--candidate-tag may be supplied only once" + candidate_tag="$2" + seen_candidate_tag=1 + ;; + --target-sha) + ((seen_target_sha == 0)) || die "--target-sha may be supplied only once" + target_sha="$2" + seen_target_sha=1 + ;; + --build) + ((seen_build == 0)) || die "--build may be supplied only once" + build="$2" + seen_build=1 + ;; + --version) + ((seen_version == 0)) || die "--version may be supplied only once" + version="$2" + seen_version=1 + ;; + --seal-output) + ((seen_seal_output == 0)) || die "--seal-output may be supplied only once" + seal_output="$2" + seen_seal_output=1 + ;; + --asset-role) asset_specs+=("$2") ;; + esac + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage + die "unknown argument: $1" + ;; + esac +done + +[[ -n "${GITHUB_REPOSITORY:-}" ]] || die "GITHUB_REPOSITORY is required" +[[ "${GITHUB_REPOSITORY}" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || die "GITHUB_REPOSITORY must be owner/repository" +[[ "${build}" =~ ^[1-9][0-9]*$ ]] || die "build must be a canonical positive decimal string" +[[ "${candidate_prefix}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*-$ ]] || die "candidate prefix must be safe and end with a hyphen" +[[ "${destination_tag}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "destination tag must be safe" +if [[ "${destination_tag}" == "rolling" ]]; then + [[ "${candidate_prefix}" == "rolling-candidate-" ]] || die "rolling candidates must use rolling-candidate-" + [[ "${candidate_tag}" == "${candidate_prefix}${build}" ]] || die "rolling candidate tag must be ${candidate_prefix}${build}" +elif [[ "${candidate_prefix}" == "rolling-candidate-" && "${destination_tag}" == "${candidate_prefix}${build}" ]]; then + # Archive candidates publish to their own permanent build tag instead of + # the mutable rolling tag or a milestone semver tag. + [[ "${candidate_tag}" == "${destination_tag}" ]] || \ + die "archive candidate tag must equal its destination tag ${destination_tag}" +else + [[ "${destination_tag}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \ + die "non-rolling destination must be a canonical milestone tag" + [[ "${version}" == "${destination_tag#v}" ]] || die "milestone version must equal the destination tag semver" + [[ "${candidate_prefix}" == "milestone-candidate-" ]] || die "milestone candidates must use milestone-candidate-" + [[ "${candidate_tag}" =~ ^${candidate_prefix}${build}-[0-9]{3}$ ]] || \ + die "milestone candidate tag must be ${candidate_prefix}${build}-" +fi +[[ "${target_sha}" =~ ^[0-9a-f]{40}$ ]] || die "target SHA must be 40 lowercase hexadecimal characters" +[[ "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || die "version must be a canonical major.minor.build value" +[[ -n "${seal_output}" ]] || die "--seal-output is required" +[[ "${seal_output}" != *$'\n'* && "${seal_output}" != *$'\r'* && "${seal_output}" != *$'\t'* ]] || \ + die "seal output path contains unsupported control characters" +seal_output_name="${seal_output##*/}" +[[ "${seal_output_name}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ && "${seal_output_name}" != "." && "${seal_output_name}" != ".." ]] || \ + die "seal output path must end in a safe filename" +seal_output_parent="${seal_output%/*}" +[[ "${seal_output_parent}" != "${seal_output}" ]] || seal_output_parent="." +[[ -d "${seal_output_parent}" && ! -L "${seal_output_parent}" ]] || die "seal output parent must be a real directory" +[[ ! -L "${seal_output}" && ! -d "${seal_output}" ]] || die "seal output must not be a symlink or directory" +((${#asset_specs[@]} == EXPECTED_ASSET_COUNT)) || die "exactly ${EXPECTED_ASSET_COUNT} payload assets are required" + +gh_command="${GH_BIN-gh}" +[[ -n "${gh_command}" ]] || die "GH_BIN must not be empty" +command -v "${gh_command}" >/dev/null 2>&1 || die "GitHub CLI command is unavailable: ${gh_command}" +command -v node >/dev/null 2>&1 || die "node is required" + +umask 077 +temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/programa-release-candidate.XXXXXX")" || die "could not create private temporary directory" +cleanup() { + rm -rf -- "${temp_dir}" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +roles=() +paths=() +names=() +sizes=() +hashes=() + +for spec in "${asset_specs[@]}"; do + [[ "${spec}" == *=* ]] || die "asset role must use role=path: ${spec}" + role="${spec%%=*}" + path="${spec#*=}" + case "${role}" in + immutable|appcast|stable-alias) ;; + *) die "invalid asset role: ${role}" ;; + esac + [[ -n "${path}" ]] || die "asset path must not be empty" + [[ "${path}" != *$'\n'* && "${path}" != *$'\r'* && "${path}" != *$'\t'* ]] || die "asset path contains unsupported control characters" + [[ -f "${path}" && -r "${path}" ]] || die "asset must be a readable regular file: ${path}" + + name="${path##*/}" + [[ -n "${name}" && "${name}" != "." && "${name}" != ".." && "${name}" != *'\'* ]] || die "asset name is not a safe basename: ${name}" + for existing_name in "${names[@]:-}"; do + [[ "${existing_name}" != "${name}" ]] || die "duplicate asset name: ${name}" + done + + size="$(file_size "${path}")" || die "could not determine asset size: ${path}" + [[ "${size}" =~ ^[1-9][0-9]*$ ]] || die "asset must be non-empty: ${path}" + ((size <= 9007199254740991)) || die "asset size exceeds JavaScript's safe integer range: ${path}" + hash="$(sha256_file "${path}")" || die "could not hash asset: ${path}" + [[ "${hash}" =~ ^[0-9a-f]{64}$ ]] || die "hashing tool returned an invalid SHA-256 for ${path}" + + roles+=("${role}") + paths+=("${path}") + names+=("${name}") + sizes+=("${size}") + hashes+=("${hash}") +done + +manifest_input="${temp_dir}/manifest-assets.tsv" +: > "${manifest_input}" +for ((index = 0; index < EXPECTED_ASSET_COUNT; index++)); do + printf '%s\t%s\t%s\t%s\n' \ + "${names[index]}" "${roles[index]}" "${sizes[index]}" "${hashes[index]}" >> "${manifest_input}" +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}" \ + "${manifest_input}" \ + "${manifest_path}" \ + "${target_sha}" \ + "${version}" \ + "${build}" \ + "${appcast_path}" \ + "${daemon_manifest_path}" \ + "${GITHUB_REPOSITORY}" \ + "${destination_tag}" <<'NODE' +"use strict"; + +const fs = require("node:fs"); +const [ + stateModulePath, + inputPath, + outputPath, + targetSha, + version, + build, + appcastPath, + daemonManifestPath, + repository, + destinationTag, +] = process.argv.slice(2); +const { createCandidateManifest, validateReleasePayloadReferences } = require(stateModulePath); + +if (typeof createCandidateManifest !== "function") { + throw new Error("rolling_release_state.js does not export createCandidateManifest"); +} +if (typeof validateReleasePayloadReferences !== "function") { + throw new Error("rolling_release_state.js does not export validateReleasePayloadReferences"); +} + +const assets = fs.readFileSync(inputPath, "utf8").trimEnd().split("\n").map((line) => { + const fields = line.split("\t"); + if (fields.length !== 4) throw new Error("invalid manifest asset input"); + const [name, role, size, sha256] = fields; + return { name, role, size: Number(size), sha256 }; +}); + +const manifest = createCandidateManifest({ + schemaVersion: 1, + sealed: true, + targetSha, + version, + build, + assets, +}); + +validateReleasePayloadReferences({ + appcastXml: fs.readFileSync(appcastPath, "utf8"), + daemonManifestJson: fs.readFileSync(daemonManifestPath, "utf8"), + repository, + tag: destinationTag, + manifest, +}); + +fs.writeFileSync(outputPath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 }); +NODE + +manifest_size="$(file_size "${manifest_path}")" || die "could not determine candidate seal size" +manifest_hash="$(sha256_file "${manifest_path}")" || die "could not hash candidate seal" + +write_seal_output() { + [[ ! -L "${seal_output}" && ! -d "${seal_output}" ]] || die "seal output became unsafe" + cp "${manifest_path}" "${seal_output}" || die "could not write local candidate seal" + chmod 600 "${seal_output}" || die "could not protect local candidate seal" + cmp -s "${manifest_path}" "${seal_output}" || die "local candidate seal bytes differ" +} + +verify_prepared_seal_output() { + [[ -f "${seal_output}" && ! -L "${seal_output}" ]] || die "prepared candidate seal is unavailable" + cmp -s "${manifest_path}" "${seal_output}" || die "prepared candidate seal bytes conflict" +} + +if [[ "${prepare_only}" == "true" ]]; then + write_seal_output + echo "Candidate ${candidate_tag} local seal is prepared." + exit 0 +fi + +# A normal invocation consumes an existing prepared seal without rewriting it. +# One-shot callers get the same bytes written before the first candidate mutation. +if [[ -e "${seal_output}" ]]; then + verify_prepared_seal_output +else + write_seal_output +fi + +expected_title="Candidate ${build}" +expected_body="candidate" + +release_value() { + local field="$1" + "${gh_command}" release view "${candidate_tag}" --repo "${GITHUB_REPOSITORY}" --json "${field}" --jq ".${field}" +} + +verify_release_metadata() { + local observed + observed="$(release_value databaseId)" || die "could not read candidate release database ID" + [[ -n "${observed}" ]] || die "candidate release has no authenticated ID" + observed="$(release_value tagName)" || die "could not read candidate tag metadata" + [[ "${observed}" == "${candidate_tag}" ]] || die "candidate release tag does not match" + observed="$(release_value isDraft)" || die "could not read candidate draft state" + [[ "${observed}" == "true" ]] || die "candidate release is not a draft" + observed="$(release_value targetCommitish)" || die "could not read candidate target" + [[ "${observed}" == "${target_sha}" ]] || die "candidate release target does not match" + observed="$(release_value name)" || die "could not read candidate title" + [[ "${observed}" == "${expected_title}" ]] || die "candidate release title does not match" + observed="$(release_value body)" || die "could not read candidate notes" + [[ "${observed}" == "${expected_body}" ]] || die "candidate release notes do not match" +} + +if release_id="$(release_value databaseId 2>"${temp_dir}/release-view.err")"; then + [[ -n "${release_id}" ]] || die "candidate release has no authenticated ID" +else + "${gh_command}" release create "${candidate_tag}" \ + --draft \ + --target "${target_sha}" \ + --title "${expected_title}" \ + --notes "${expected_body}" \ + --repo "${GITHUB_REPOSITORY}" +fi +verify_release_metadata + +list_assets() { + "${gh_command}" release view "${candidate_tag}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json assets \ + --jq '.assets[] | [.id, .name, .state, (.size | tostring), (.digest // "")] | @tsv' +} + +is_expected_name() { + local candidate_name="$1" expected_name + [[ "${candidate_name}" == "${SEAL_NAME}" ]] && return 0 + for expected_name in "${names[@]}"; do + [[ "${candidate_name}" != "${expected_name}" ]] || return 0 + done + return 1 +} + +refresh_asset_listing() { + local asset_id asset_name asset_state asset_size asset_digest extra + list_assets > "${temp_dir}/assets.tsv" || die "could not read candidate assets" + : > "${temp_dir}/seen-assets" + while IFS=$'\t' read -r asset_id asset_name asset_state asset_size asset_digest extra; do + [[ -n "${asset_name}" ]] || continue + [[ -z "${extra:-}" ]] || die "candidate asset metadata has unexpected fields" + is_expected_name "${asset_name}" || die "candidate contains unexpected asset: ${asset_name}" + if grep -Fqx "${asset_name}" "${temp_dir}/seen-assets"; then + die "candidate contains duplicate asset metadata: ${asset_name}" + fi + printf '%s\n' "${asset_name}" >> "${temp_dir}/seen-assets" + done < "${temp_dir}/assets.tsv" +} + +asset_metadata() { + local wanted="$1" asset_id asset_name asset_state asset_size asset_digest extra matches=0 + while IFS=$'\t' read -r asset_id asset_name asset_state asset_size asset_digest extra; do + [[ "${asset_name}" == "${wanted}" ]] || continue + ((matches += 1)) + printf '%s\t%s\t%s\t%s\n' "${asset_id}" "${asset_state}" "${asset_size}" "${asset_digest}" + done < "${temp_dir}/assets.tsv" + ((matches <= 1)) || die "candidate contains duplicate asset metadata: ${wanted}" + ((matches == 1)) +} + +verify_asset() { + local name="$1" expected_path="$2" expected_size="$3" expected_hash="$4" + local metadata asset_id state size digest download_dir downloaded observed_size observed_hash + + refresh_asset_listing + metadata="$(asset_metadata "${name}")" || die "candidate asset is missing after upload: ${name}" + IFS=$'\t' read -r asset_id state size digest <<< "${metadata}" + [[ -n "${asset_id}" ]] || die "candidate asset has no authenticated ID: ${name}" + [[ "${state}" == "uploaded" ]] || die "candidate asset is not uploaded: ${name}" + [[ "${size}" == "${expected_size}" ]] || die "candidate asset size conflicts: ${name}" + if [[ -n "${digest}" ]]; then + [[ "${digest}" == "sha256:${expected_hash}" ]] || die "candidate asset digest conflicts: ${name}" + fi + + verify_release_metadata + download_dir="$(mktemp -d "${temp_dir}/download.XXXXXX")" || die "could not create asset verification directory" + "${gh_command}" release download "${candidate_tag}" \ + --pattern "${name}" \ + --dir "${download_dir}" \ + --repo "${GITHUB_REPOSITORY}" || die "could not download candidate asset: ${name}" + downloaded="${download_dir}/${name}" + [[ -f "${downloaded}" && -r "${downloaded}" ]] || die "downloaded candidate asset is unavailable: ${name}" + observed_size="$(file_size "${downloaded}")" || die "could not determine downloaded asset size: ${name}" + [[ "${observed_size}" == "${expected_size}" ]] || die "downloaded candidate asset size conflicts: ${name}" + observed_hash="$(sha256_file "${downloaded}")" || die "could not hash downloaded candidate asset: ${name}" + [[ "${observed_hash}" == "${expected_hash}" ]] || die "downloaded candidate asset bytes conflict: ${name}" + cmp -s "${downloaded}" "${expected_path}" || die "downloaded candidate asset bytes differ: ${name}" +} + +upload_or_verify_payload() { + local index="$1" metadata + refresh_asset_listing + if metadata="$(asset_metadata "${names[index]}")"; then + verify_asset "${names[index]}" "${paths[index]}" "${sizes[index]}" "${hashes[index]}" + return + fi + + verify_release_metadata + "${gh_command}" release upload "${candidate_tag}" "${paths[index]}" --repo "${GITHUB_REPOSITORY}" || \ + die "could not upload candidate asset: ${names[index]}" + verify_asset "${names[index]}" "${paths[index]}" "${sizes[index]}" "${hashes[index]}" +} + +upload_payload_named() { + local wanted="$1" index + for ((index = 0; index < EXPECTED_ASSET_COUNT; index++)); do + if [[ "${names[index]}" == "${wanted}" ]]; then + upload_or_verify_payload "${index}" + return + fi + done + die "validated manifest is missing payload: ${wanted}" +} + +refresh_asset_listing +if asset_metadata "${SEAL_NAME}" >/dev/null; then + verify_asset "${SEAL_NAME}" "${manifest_path}" "${manifest_size}" "${manifest_hash}" + for ((index = 0; index < EXPECTED_ASSET_COUNT; index++)); do + verify_asset "${names[index]}" "${paths[index]}" "${sizes[index]}" "${hashes[index]}" + done + verify_release_metadata + verify_prepared_seal_output + echo "Candidate ${candidate_tag} is already sealed and verified." + exit 0 +fi + +# Reject every conflicting existing payload before adding anything to a partial draft. +for ((index = 0; index < EXPECTED_ASSET_COUNT; index++)); do + refresh_asset_listing + if asset_metadata "${names[index]}" >/dev/null; then + verify_asset "${names[index]}" "${paths[index]}" "${sizes[index]}" "${hashes[index]}" + fi +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" + +verify_release_metadata +refresh_asset_listing +if asset_metadata "${SEAL_NAME}" >/dev/null; then + die "candidate seal appeared unexpectedly" +fi + +# Upload from the temp copy: gh names the asset after the local file, and the +# --seal-output path is caller-chosen. The bytes were verified identical above. +"${gh_command}" release upload "${candidate_tag}" "${manifest_path}" --repo "${GITHUB_REPOSITORY}" || \ + die "could not upload candidate seal" +verify_asset "${SEAL_NAME}" "${manifest_path}" "${manifest_size}" "${manifest_hash}" + +refresh_asset_listing +asset_total="$(wc -l < "${temp_dir}/seen-assets" | tr -d '[:space:]')" +((asset_total == EXPECTED_ASSET_COUNT + 1)) || die "sealed candidate does not contain the exact payload set" +verify_release_metadata +echo "Candidate ${candidate_tag} is sealed and verified." diff --git a/scripts/publish_rolling_release.sh b/scripts/publish_rolling_release.sh new file mode 100755 index 00000000..ba1840d9 --- /dev/null +++ b/scripts/publish_rolling_release.sh @@ -0,0 +1,769 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE_MODULE="${SCRIPT_DIR}/rolling_release_state.js" +GH_BIN="${GH_BIN:-gh}" +REPOSITORY="${GITHUB_REPOSITORY:-}" +CANDIDATE_PREFIX="" +ROLLING_TAG="" +RECONCILER_TARGET_SHA="" +SEAL_NAME="programa-release-candidate.json" +TEMP_DIR="" + +usage() { + cat >&2 <<'EOF' +Usage: publish_rolling_release.sh \ + --candidate-prefix \ + --rolling-tag \ + --reconciler-target-sha <40-lowercase-hex> +EOF +} + +fail() { + echo "publish_rolling_release.sh: $*" >&2 + exit 1 +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi + exit "${status}" +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + fail "neither shasum nor sha256sum is available" + fi +} + +file_size() { + stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1" +} + +query_assets() { + local tag="$1" output="$2" + "${GH_BIN}" release view "${tag}" \ + --repo "${REPOSITORY}" \ + --json assets \ + --jq '.assets[] | [.id, .name, .state, .size, (.digest // "-")] | @tsv' > "${output}" +} + +asset_metadata_line() { + local name="$1" metadata_file="$2" + awk -F '\t' -v expected="${name}" ' + $2 == expected { print; matches += 1 } + END { if (matches > 1) exit 2 } + ' "${metadata_file}" +} + +verify_asset_strict() { + local tag="$1" name="$2" expected_size="$3" expected_sha="$4" + local metadata_file="$5" download_dir="$6" + local line asset_id stored_name state size digest downloaded actual_size actual_sha + + line="$(asset_metadata_line "${name}" "${metadata_file}")" || \ + fail "release ${tag} has duplicate metadata for ${name}" + [[ -n "${line}" ]] || fail "release ${tag} is missing asset ${name}" + IFS=$'\t' read -r asset_id stored_name state size digest <<< "${line}" + [[ -n "${asset_id}" && "${stored_name}" == "${name}" ]] || \ + fail "release ${tag} has malformed metadata for ${name}" + [[ "${state}" == "uploaded" ]] || fail "release ${tag} asset ${name} is not uploaded" + + mkdir -p "${download_dir}" + downloaded="${download_dir}/${name}" + rm -f "${downloaded}" + "${GH_BIN}" release download "${tag}" \ + --repo "${REPOSITORY}" \ + --pattern "${name}" \ + --dir "${download_dir}" + [[ -f "${downloaded}" ]] || fail "authenticated download omitted ${tag}/${name}" + + actual_size="$(file_size "${downloaded}")" + actual_sha="$(sha256_file "${downloaded}")" + [[ "${size}" == "${actual_size}" ]] || fail "release ${tag} asset ${name} has incorrect size metadata" + if [[ -n "${digest:-}" && "${digest}" != "-" && "${digest}" != "null" ]]; then + [[ "${digest}" == "sha256:${actual_sha}" ]] || \ + fail "release ${tag} asset ${name} has incorrect digest metadata" + fi + if [[ -n "${expected_size}" ]]; then + [[ "${actual_size}" == "${expected_size}" ]] || \ + fail "release ${tag} asset ${name} has unexpected bytes" + fi + if [[ -n "${expected_sha}" ]]; then + [[ "${actual_sha}" == "${expected_sha}" ]] || \ + fail "release ${tag} asset ${name} failed authenticated SHA-256 verification" + fi +} + +asset_matches() { + local tag="$1" name="$2" expected_size="$3" expected_sha="$4" + local metadata_file="$5" download_dir="$6" + local line asset_id stored_name state size digest downloaded actual_size actual_sha + + line="$(asset_metadata_line "${name}" "${metadata_file}")" || return 1 + [[ -n "${line}" ]] || return 1 + IFS=$'\t' read -r asset_id stored_name state size digest <<< "${line}" + [[ -n "${asset_id}" && "${stored_name}" == "${name}" ]] || return 1 + [[ "${state}" == "uploaded" && "${size}" == "${expected_size}" ]] || return 1 + if [[ -n "${digest:-}" && "${digest}" != "-" && "${digest}" != "null" ]]; then + [[ "${digest}" == "sha256:${expected_sha}" ]] || return 1 + fi + + mkdir -p "${download_dir}" + downloaded="${download_dir}/${name}" + rm -f "${downloaded}" + if ! "${GH_BIN}" release download "${tag}" \ + --repo "${REPOSITORY}" \ + --pattern "${name}" \ + --dir "${download_dir}"; then + return 1 + fi + [[ -f "${downloaded}" ]] || return 1 + actual_size="$(file_size "${downloaded}")" + actual_sha="$(sha256_file "${downloaded}")" + [[ "${actual_size}" == "${expected_size}" && "${actual_sha}" == "${expected_sha}" ]] +} + +download_appcast_if_present() { + local tag="$1" metadata_file="$2" download_dir="$3" + local line + line="$(asset_metadata_line appcast.xml "${metadata_file}")" || \ + fail "release ${tag} has duplicate metadata for appcast.xml" + [[ -n "${line}" ]] || return 1 + verify_asset_strict "${tag}" appcast.xml "" "" "${metadata_file}" "${download_dir}" +} + +build_is_at_most() { + node -e 'process.exit(BigInt(process.argv[1]) <= BigInt(process.argv[2]) ? 0 : 1)' "$1" "$2" +} + +require_selected_target_is_current_main() { + local checkpoint="$1" current_main + current_main="$("${GH_BIN}" api \ + "repos/${REPOSITORY}/git/ref/heads/main" \ + --jq .object.sha)" || fail "could not read current main ref at ${checkpoint}" + [[ "${current_main}" =~ ^[0-9a-f]{40}$ ]] || \ + fail "current main ref did not resolve to a commit SHA at ${checkpoint}" + [[ "${current_main}" == "${SELECTED_TARGET}" ]] || \ + fail "candidate target ${SELECTED_TARGET} is no longer current main ${current_main} at ${checkpoint}" +} + +query_releases_paginated() { + local output="$1" + "${GH_BIN}" api --paginate \ + "repos/${REPOSITORY}/releases?per_page=100" \ + --jq '.[] | [.tag_name, .draft, .prerelease, .immutable, .target_commitish] | @tsv' \ + > "${output}" +} + +prune_candidates() { + local finalized_build="$1" skip_tag="${2:-}" + local tag is_draft is_prerelease is_immutable target suffix + while IFS=$'\t' read -r tag is_draft is_prerelease is_immutable target; do + [[ "${is_draft}" == "true" && "${tag}" == "${CANDIDATE_PREFIX}"* ]] || continue + [[ "${tag}" != "${skip_tag}" ]] || continue + suffix="${tag#"${CANDIDATE_PREFIX}"}" + [[ "${suffix}" =~ ^[0-9]+$ ]] || continue + suffix="$((10#${suffix}))" + if build_is_at_most "${suffix}" "${finalized_build}"; then + "${GH_BIN}" release delete "${tag}" --repo "${REPOSITORY}" --yes + fi + done < "${RELEASE_LIST}" +} + +snapshot_public_high_water() { + local snapshot_name="$1" snapshot_dir releases metadata appcast_paths + local release_tag is_draft is_prerelease is_immutable release_target release_index release_metadata release_appcast + local archive_suffix + snapshot_dir="${TEMP_DIR}/public-snapshot-${snapshot_name}" + releases="${snapshot_dir}/releases.tsv" + metadata="${snapshot_dir}/rolling-assets.tsv" + appcast_paths="${snapshot_dir}/published-appcast-paths.txt" + mkdir -p "${snapshot_dir}" + : > "${appcast_paths}" + + query_releases_paginated "${releases}" + + if awk -F '\t' -v expected="${ROLLING_TAG}" \ + '$1 == expected && $2 == "false" { found = 1 } END { exit !found }' "${releases}"; then + query_assets "${ROLLING_TAG}" "${metadata}" + else + : > "${metadata}" + fi + + release_index=0 + while IFS=$'\t' read -r release_tag is_draft is_prerelease is_immutable release_target; do + [[ "${is_draft}" == "false" ]] || continue + + if [[ "${release_tag}" == "${CANDIDATE_PREFIX}"* ]]; then + archive_suffix="${release_tag#"${CANDIDATE_PREFIX}"}" + if [[ "${archive_suffix}" =~ ^[1-9][0-9]*$ ]]; then + [[ "${is_prerelease}" == "true" ]] || \ + fail "published archive ${release_tag} must be a prerelease" + [[ "${is_immutable}" == "false" ]] || \ + fail "published archive ${release_tag} must remain mutable" + continue + fi + fi + + # Candidate archives and arbitrary prereleases are not public feed high-water. + # Rolling assets and published non-prerelease appcasts remain authoritative. + [[ "${is_prerelease}" == "false" ]] || continue + release_index=$((release_index + 1)) + release_metadata="${snapshot_dir}/release-${release_index}-assets.tsv" + release_appcast="${snapshot_dir}/release-${release_index}-appcast/appcast.xml" + if [[ "${release_tag}" == "${ROLLING_TAG}" ]]; then + release_metadata="${metadata}" + else + query_assets "${release_tag}" "${release_metadata}" + fi + if download_appcast_if_present \ + "${release_tag}" "${release_metadata}" "$(dirname "${release_appcast}")"; then + printf '%s\n' "${release_appcast}" >> "${appcast_paths}" + fi + done < "${releases}" + + node - "${STATE_MODULE}" "${metadata}" "${appcast_paths}" <<'NODE' +const fs = require("node:fs"); +const [modulePath, metadataPath, appcastPathsPath] = process.argv.slice(2); +const { derivePublicHighWater } = require(modulePath); +const names = fs.readFileSync(metadataPath, "utf8").split(/\n/).filter(Boolean).map((line) => line.split("\t")[1]); +const publishedAppcastXmls = fs.readFileSync(appcastPathsPath, "utf8") + .split(/\n/) + .filter(Boolean) + .map((path) => fs.readFileSync(path, "utf8")); +const build = derivePublicHighWater({ + rollingAssetNames: names, + rollingAppcastXml: null, + publishedMilestoneAppcastXmls: publishedAppcastXmls, +}); +if (build !== null) process.stdout.write(build); +NODE +} + +promotion_action_for() { + local high_water="$1" + node - "${STATE_MODULE}" "${SELECTED_MANIFEST}" "${high_water}" <<'NODE' +const [modulePath, manifestPath, highWaterValue] = process.argv.slice(2); +const { assertCandidateMayPromote } = require(modulePath); +const candidate = require(manifestPath); +const highWater = highWaterValue || null; +try { + process.stdout.write(assertCandidateMayPromote(candidate, highWater)); +} catch (error) { + if (highWater !== null && BigInt(candidate.build) < BigInt(highWater)) { + process.stdout.write("reject"); + } else { + throw error; + } +} +NODE +} + +reconcile_role() { + local expected_role="$1" name role size sha current_metadata + while IFS=$'\t' read -r name role size sha; do + [[ "${role}" == "${expected_role}" ]] || continue + current_metadata="${TEMP_DIR}/rolling-current-${name}.tsv" + query_assets "${ROLLING_TAG}" "${current_metadata}" + if asset_matches "${ROLLING_TAG}" "${name}" "${size}" "${sha}" \ + "${current_metadata}" "${TEMP_DIR}/rolling-match-${name}"; then + continue + fi + + "${GH_BIN}" release upload "${ROLLING_TAG}" \ + "${SELECTED_PAYLOAD_DIR}/${name}" \ + --repo "${REPOSITORY}" \ + --clobber + query_assets "${ROLLING_TAG}" "${current_metadata}" + verify_asset_strict "${ROLLING_TAG}" "${name}" "${size}" "${sha}" \ + "${current_metadata}" "${TEMP_DIR}/rolling-upload-verification-${name}" + done < "${PROMOTION_ORDER}" +} + +verify_selected_archive() { + local states row tag draft prerelease immutable target metadata post_seal_path + states="${TEMP_DIR}/selected-archive-states.tsv" + query_releases_paginated "${states}" || fail "could not list releases while verifying selected archive" + row="$(awk -F '\t' -v expected="${SELECTED_TAG}" ' + $1 == expected { print; matches += 1 } + END { if (matches != 1) exit 2 } + ' "${states}")" || fail "selected archive release is missing or ambiguous" + IFS=$'\t' read -r tag draft prerelease immutable target <<< "${row}" + [[ "${tag}" == "${SELECTED_TAG}" ]] || fail "selected archive resolved to an unexpected tag" + [[ "${draft}" == "false" ]] || fail "selected archive is still a draft" + [[ "${prerelease}" == "true" ]] || fail "selected archive must be a prerelease" + # GitHub excludes prereleases from the latest-release surface; publication also + # explicitly supplies --latest=false below. + [[ "${target}" == "${SELECTED_TARGET}" ]] || \ + fail "selected archive target changed after publication" + [[ "${immutable}" == "false" ]] || fail "selected archive must remain mutable" + + metadata="${TEMP_DIR}/selected-archive-public-assets.tsv" + query_assets "${SELECTED_TAG}" "${metadata}" + cut -f2 "${metadata}" | LC_ALL=C sort > "${TEMP_DIR}/selected-archive-public-names.txt" + cmp -s \ + "${TEMP_DIR}/selected-archive-public-names.txt" \ + "${TEMP_DIR}/candidate-expected-names.sorted.txt" || \ + fail "selected archive does not contain exactly ten payloads plus its seal" + while IFS=$'\t' read -r name role size sha; do + verify_asset_strict "${SELECTED_TAG}" "${name}" "${size}" "${sha}" \ + "${metadata}" "${TEMP_DIR}/selected-archive-public-verification" + done < "${PROMOTION_ORDER}" + verify_asset_strict "${SELECTED_TAG}" "${SEAL_NAME}" "${SELECTED_SEAL_SIZE}" "${SELECTED_SEAL_SHA}" \ + "${metadata}" "${TEMP_DIR}/selected-archive-public-seal-verification" + post_seal_path="${TEMP_DIR}/selected-archive-public-seal-verification/${SEAL_NAME}" + "${GH_BIN}" attestation verify "${post_seal_path}" \ + --repo "${REPOSITORY}" \ + --signer-workflow "${REPOSITORY}/.github/workflows/release.yml" \ + --source-ref refs/heads/main \ + --source-digest "${SELECTED_TARGET}" \ + --deny-self-hosted-runners +} + +verify_rolling_aliases() { + local metadata="$1" destination="$2" name role size sha + while IFS=$'\t' read -r name role size sha; do + [[ "${role}" == "appcast" || "${role}" == "stable-alias" ]] || continue + verify_asset_strict "${ROLLING_TAG}" "${name}" "${size}" "${sha}" \ + "${metadata}" "${destination}-${name}" + done < "${PROMOTION_ORDER}" +} + +while (($#)); do + case "$1" in + --candidate-prefix) + [[ "$#" -ge 2 ]] || { usage; fail "--candidate-prefix requires a value"; } + CANDIDATE_PREFIX="$2" + shift 2 + ;; + --rolling-tag) + [[ "$#" -ge 2 ]] || { usage; fail "--rolling-tag requires a value"; } + ROLLING_TAG="$2" + shift 2 + ;; + --reconciler-target-sha) + [[ "$#" -ge 2 ]] || { usage; fail "--reconciler-target-sha requires a value"; } + [[ -z "${RECONCILER_TARGET_SHA}" ]] || fail "--reconciler-target-sha may be supplied only once" + RECONCILER_TARGET_SHA="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "${REPOSITORY}" ]] || fail "GITHUB_REPOSITORY is required" +[[ -n "${CANDIDATE_PREFIX}" ]] || fail "--candidate-prefix is required" +[[ -n "${ROLLING_TAG}" ]] || fail "--rolling-tag is required" +[[ "${RECONCILER_TARGET_SHA}" =~ ^[0-9a-f]{40}$ ]] || \ + fail "--reconciler-target-sha must be 40 lowercase hexadecimal characters" +[[ "${CANDIDATE_PREFIX}" =~ ^[A-Za-z0-9._-]+$ ]] || fail "candidate prefix contains unsafe characters" +[[ "${ROLLING_TAG}" =~ ^[A-Za-z0-9._-]+$ ]] || fail "rolling tag contains unsafe characters" +[[ -r "${STATE_MODULE}" ]] || fail "missing state module: ${STATE_MODULE}" +GH_BIN="$(command -v "${GH_BIN}")" || fail "GH_BIN is not executable" +command -v node >/dev/null || fail "node is required" + +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-rolling-reconciler.XXXXXX")" +trap cleanup EXIT INT TERM +RELEASE_LIST="${TEMP_DIR}/releases.tsv" +CANDIDATES_JSONL="${TEMP_DIR}/candidates.jsonl" +: > "${CANDIDATES_JSONL}" + +query_releases_paginated "${RELEASE_LIST}" + +ROLLING_EXISTS=false +ROLLING_PUBLISHED_AT_START=false +ROLLING_IMMUTABLE_AT_START="" +candidate_index=0 +sealed_candidate_count=0 +while IFS=$'\t' read -r tag is_draft is_prerelease is_immutable candidate_target; do + if [[ "${tag}" == "${ROLLING_TAG}" ]]; then + ROLLING_EXISTS=true + ROLLING_IMMUTABLE_AT_START="${is_immutable}" + if [[ "${is_draft}" == "false" ]]; then + ROLLING_PUBLISHED_AT_START=true + fi + fi + [[ "${tag}" == "${CANDIDATE_PREFIX}"* ]] || continue + candidate_suffix="${tag#"${CANDIDATE_PREFIX}"}" + [[ "${candidate_suffix}" =~ ^[1-9][0-9]*$ ]] || continue + if [[ "${is_draft}" == "false" ]]; then + [[ "${is_prerelease}" == "true" ]] || \ + fail "published archive ${tag} must be a prerelease" + [[ "${is_immutable}" == "false" ]] || fail "published archive ${tag} must remain mutable" + else + [[ "${is_draft}" == "true" ]] || fail "candidate ${tag} has an invalid draft state" + fi + + candidate_index=$((candidate_index + 1)) + candidate_dir="${TEMP_DIR}/candidate-${candidate_index}" + candidate_metadata="${candidate_dir}/assets.tsv" + mkdir -p "${candidate_dir}" + query_assets "${tag}" "${candidate_metadata}" + seal_line="$(asset_metadata_line "${SEAL_NAME}" "${candidate_metadata}")" || \ + fail "candidate ${tag} has duplicate seal assets" + if [[ -z "${seal_line}" ]]; then + [[ "${is_draft}" == "true" ]] || fail "published archive ${tag} is missing its seal" + continue + fi + + verify_asset_strict "${tag}" "${SEAL_NAME}" "" "" \ + "${candidate_metadata}" "${candidate_dir}/seal" + normalized_manifest="${candidate_dir}/manifest.json" + node - "${STATE_MODULE}" "${candidate_dir}/seal/${SEAL_NAME}" > "${normalized_manifest}" <<'NODE' +const fs = require("node:fs"); +const [modulePath, manifestPath] = process.argv.slice(2); +const { validateCandidateManifest } = require(modulePath); +const value = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const manifest = validateCandidateManifest(value); +if (!manifest.sealed) throw new TypeError("candidate seal must declare sealed: true"); +process.stdout.write(`${JSON.stringify(manifest)}\n`); +NODE + sealed_candidate_count=$((sealed_candidate_count + 1)) + + IFS=$'\t' read -r manifest_build manifest_target manifest_version < <( + node -e ' + const value = require(process.argv[1]); + process.stdout.write(`${value.build}\t${value.targetSha}\t${value.version}\n`); + ' "${normalized_manifest}" + ) + [[ "${tag}" == "${CANDIDATE_PREFIX}${manifest_build}" ]] || \ + fail "candidate tag ${tag} disagrees with sealed build ${manifest_build}" + [[ "${candidate_target}" == "${manifest_target}" ]] || \ + fail "candidate ${tag} target disagrees with its seal" + if [[ "${manifest_target}" == "${RECONCILER_TARGET_SHA}" ]]; then + cat "${normalized_manifest}" >> "${CANDIDATES_JSONL}" + fi +done < "${RELEASE_LIST}" + +SELECTED_MANIFEST="${TEMP_DIR}/selected-manifest.json" +node - "${STATE_MODULE}" "${CANDIDATES_JSONL}" > "${SELECTED_MANIFEST}" <<'NODE' +const fs = require("node:fs"); +const [modulePath, candidatesPath] = process.argv.slice(2); +const { selectPromotionCandidate } = require(modulePath); +const lines = fs.readFileSync(candidatesPath, "utf8").split(/\n/).filter(Boolean); +const selected = selectPromotionCandidate(lines.map((line) => JSON.parse(line))); +if (selected !== null) process.stdout.write(`${JSON.stringify(selected)}\n`); +NODE + +if [[ ! -s "${SELECTED_MANIFEST}" ]]; then + if ((sealed_candidate_count > 0)); then + UNMATCHED_CURRENT_MAIN="$("${GH_BIN}" api \ + "repos/${REPOSITORY}/git/ref/heads/main" \ + --jq .object.sha)" || \ + fail "could not read current main ref while diagnosing an unmatched reconciler target" + [[ "${UNMATCHED_CURRENT_MAIN}" == "${RECONCILER_TARGET_SHA}" ]] || \ + fail "reconciler target ${RECONCILER_TARGET_SHA} is no longer current main ${UNMATCHED_CURRENT_MAIN}" + fail "no sealed candidate matches reconciler target ${RECONCILER_TARGET_SHA}" + fi + exit 0 +fi + +IFS=$'\t' read -r SELECTED_BUILD SELECTED_TARGET SELECTED_VERSION < <( + node -e ' + const value = require(process.argv[1]); + process.stdout.write(`${value.build}\t${value.targetSha}\t${value.version}\n`); + ' "${SELECTED_MANIFEST}" +) +SELECTED_TAG="${CANDIDATE_PREFIX}${SELECTED_BUILD}" +[[ "${SELECTED_TARGET}" == "${RECONCILER_TARGET_SHA}" ]] || \ + fail "selected candidate target ${SELECTED_TARGET} does not match reconciler target ${RECONCILER_TARGET_SHA}" +SELECTED_STATE_ROW="$(awk -F '\t' -v expected="${SELECTED_TAG}" ' + $1 == expected { print; matches += 1 } + END { if (matches != 1) exit 2 } +' "${RELEASE_LIST}")" || fail "selected candidate release state is missing or ambiguous" +IFS=$'\t' read -r \ + selected_state_tag \ + SELECTED_INITIAL_DRAFT \ + SELECTED_INITIAL_PRERELEASE \ + SELECTED_INITIAL_IMMUTABLE \ + selected_state_target <<< "${SELECTED_STATE_ROW}" +[[ "${selected_state_tag}" == "${SELECTED_TAG}" ]] || fail "selected candidate state resolved to an unexpected tag" +[[ "${SELECTED_INITIAL_DRAFT}" == "true" || "${SELECTED_INITIAL_DRAFT}" == "false" ]] || \ + fail "selected candidate has an invalid draft state" +[[ "${selected_state_target}" == "${SELECTED_TARGET}" ]] || fail "selected candidate state has an unexpected target" +if [[ "${SELECTED_INITIAL_DRAFT}" == "false" ]]; then + [[ "${SELECTED_INITIAL_PRERELEASE}" == "true" ]] || \ + fail "published selected archive must be a prerelease" + [[ "${SELECTED_INITIAL_IMMUTABLE}" == "false" ]] || \ + fail "published selected archive must remain mutable" +fi +PROMOTION_ORDER="${TEMP_DIR}/promotion-order.tsv" +node - "${STATE_MODULE}" "${SELECTED_MANIFEST}" > "${PROMOTION_ORDER}" <<'NODE' +const [modulePath, manifestPath] = process.argv.slice(2); +const { getPromotionOrder } = require(modulePath); +const manifest = require(manifestPath); +for (const asset of getPromotionOrder(manifest)) { + process.stdout.write(`${asset.name}\t${asset.role}\t${asset.size}\t${asset.sha256}\n`); +} +NODE + +SELECTED_METADATA="${TEMP_DIR}/selected-assets.tsv" +query_assets "${SELECTED_TAG}" "${SELECTED_METADATA}" +cut -f2 "${SELECTED_METADATA}" | LC_ALL=C sort > "${TEMP_DIR}/candidate-actual-names.txt" +cut -f1 "${PROMOTION_ORDER}" > "${TEMP_DIR}/candidate-expected-names.txt" +printf '%s\n' "${SEAL_NAME}" >> "${TEMP_DIR}/candidate-expected-names.txt" +LC_ALL=C sort "${TEMP_DIR}/candidate-expected-names.txt" > "${TEMP_DIR}/candidate-expected-names.sorted.txt" +cmp -s "${TEMP_DIR}/candidate-actual-names.txt" "${TEMP_DIR}/candidate-expected-names.sorted.txt" || \ + fail "candidate ${SELECTED_TAG} does not contain exactly ten payloads plus its seal" + +SELECTED_PAYLOAD_DIR="${TEMP_DIR}/selected-payload" +while IFS=$'\t' read -r name role size sha; do + verify_asset_strict "${SELECTED_TAG}" "${name}" "${size}" "${sha}" \ + "${SELECTED_METADATA}" "${SELECTED_PAYLOAD_DIR}" +done < "${PROMOTION_ORDER}" +verify_asset_strict "${SELECTED_TAG}" "${SEAL_NAME}" "" "" \ + "${SELECTED_METADATA}" "${TEMP_DIR}/selected-seal-verification" +SELECTED_SEAL_PATH="${TEMP_DIR}/selected-seal-verification/${SEAL_NAME}" +SELECTED_SEAL_SIZE="$(file_size "${SELECTED_SEAL_PATH}")" +SELECTED_SEAL_SHA="$(sha256_file "${SELECTED_SEAL_PATH}")" + +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 { validateReleasePayloadReferences } = require(modulePath); +validateReleasePayloadReferences({ + appcastXml: fs.readFileSync(appcastPath, "utf8"), + daemonManifestJson: fs.readFileSync(daemonManifestPath, "utf8"), + repository, + tag, + manifest: require(manifestPath), +}); +NODE + +require_selected_target_is_current_main "initial provenance gate" + +"${GH_BIN}" attestation verify \ + "${SELECTED_SEAL_PATH}" \ + --repo "${REPOSITORY}" \ + --signer-workflow "${REPOSITORY}/.github/workflows/release.yml" \ + --source-ref refs/heads/main \ + --source-digest "${SELECTED_TARGET}" \ + --deny-self-hosted-runners + +while IFS=$'\t' read -r name role size sha; do + "${GH_BIN}" attestation verify "${SELECTED_PAYLOAD_DIR}/${name}" \ + --repo "${REPOSITORY}" \ + --signer-workflow "${REPOSITORY}/.github/workflows/release.yml" \ + --source-ref refs/heads/main \ + --source-digest "${SELECTED_TARGET}" \ + --deny-self-hosted-runners +done < "${PROMOTION_ORDER}" + +CI_MATCH_COUNT="$("${GH_BIN}" api \ + "repos/${REPOSITORY}/actions/workflows/ci.yml/runs" \ + -X GET \ + -f "head_sha=${SELECTED_TARGET}" \ + -f branch=main \ + -f event=push \ + -f status=completed \ + --jq "[.workflow_runs[] | select(.head_sha == \"${SELECTED_TARGET}\" and .head_branch == \"main\" and .event == \"push\" and .status == \"completed\" and .conclusion == \"success\")] | length")" +[[ "${CI_MATCH_COUNT}" =~ ^[1-9][0-9]*$ ]] || \ + fail "candidate target ${SELECTED_TARGET} has no completed successful main-branch push CI run" + +HIGH_WATER="$(snapshot_public_high_water initial)" +PROMOTION_ACTION="$(promotion_action_for "${HIGH_WATER}")" + +if [[ "${PROMOTION_ACTION}" == "reject" ]]; then + prune_candidates "${HIGH_WATER}" + exit 0 +fi +[[ "${PROMOTION_ACTION}" == "repair" || "${PROMOTION_ACTION}" == "promote" ]] || \ + fail "state module returned an unknown promotion action" + +[[ "${ROLLING_EXISTS}" == "true" && "${ROLLING_PUBLISHED_AT_START}" == "true" ]] || \ + fail "rolling must already exist as a published legacy mutable release" +[[ "${ROLLING_IMMUTABLE_AT_START}" == "false" ]] || \ + fail "rolling must remain a legacy mutable release" + +if [[ "${SELECTED_INITIAL_DRAFT}" == "true" ]]; then + require_selected_target_is_current_main "archive publication gate" + "${GH_BIN}" release edit "${SELECTED_TAG}" \ + --repo "${REPOSITORY}" \ + --draft=false \ + --prerelease=true \ + --latest=false +fi +verify_selected_archive + +RACE_HIGH_WATER="$(snapshot_public_high_water post-archive)" +RACE_ACTION="$(promotion_action_for "${RACE_HIGH_WATER}")" +if [[ "${RACE_ACTION}" == "reject" ]]; then + fail "public high-water advanced to ${RACE_HIGH_WATER} during archive publication" +fi +[[ "${RACE_ACTION}" == "repair" || "${RACE_ACTION}" == "promote" ]] || \ + fail "state module returned an unknown post-archive promotion action" +require_selected_target_is_current_main "alias publication gate" +reconcile_role appcast +reconcile_role stable-alias + +CONVERGED_METADATA="${TEMP_DIR}/rolling-converged-assets.tsv" +query_assets "${ROLLING_TAG}" "${CONVERGED_METADATA}" +verify_rolling_aliases "${CONVERGED_METADATA}" "${TEMP_DIR}/rolling-prepublish-verification" + +STARTING_REF="" +if STARTING_REF="$("${GH_BIN}" api "repos/${REPOSITORY}/git/ref/tags/${ROLLING_TAG}" --jq .object.sha 2>/dev/null)"; then + [[ "${STARTING_REF}" =~ ^[0-9a-f]{40}$ ]] || fail "rolling ref did not resolve to a commit SHA" +elif [[ "${ROLLING_PUBLISHED_AT_START}" == "true" ]]; then + fail "published rolling release has no readable git ref" +fi + +PRESERVE_PUBLISHED_METADATA=false +PUBLISHED_BODY="${TEMP_DIR}/published-body.md" +if [[ "${ROLLING_PUBLISHED_AT_START}" == "true" && "${STARTING_REF}" == "${SELECTED_TARGET}" ]]; then + published_states="${TEMP_DIR}/published-rolling-states.tsv" + query_releases_paginated "${published_states}" || \ + fail "could not enumerate releases while preserving published rolling metadata" + published_state="$(awk -F '\t' -v expected="${ROLLING_TAG}" ' + $1 == expected { row = $0; matches += 1 } + END { + if (matches == 1) print row + else exit 2 + } + ' "${published_states}")" || fail "published rolling release state is missing or ambiguous" + IFS=$'\t' read -r \ + published_tag \ + published_draft \ + published_prerelease \ + published_immutable \ + published_target <<< "${published_state}" + [[ "${published_tag}" == "${ROLLING_TAG}" ]] || fail "published rolling state resolved to an unexpected tag" + published_title="$("${GH_BIN}" release view "${ROLLING_TAG}" \ + --repo "${REPOSITORY}" --json name --jq .name)" || fail "could not read published rolling title" + "${GH_BIN}" release view "${ROLLING_TAG}" \ + --repo "${REPOSITORY}" --json body --jq .body > "${PUBLISHED_BODY}" || \ + fail "could not read published rolling notes" + [[ "${published_title}" == "Rolling ${SELECTED_VERSION}" ]] || \ + fail "published rolling title conflicts with the selected candidate" + [[ "${published_draft}" == "false" ]] || fail "published rolling release is still a draft" + [[ "${published_prerelease}" == "false" ]] || fail "published rolling release is a prerelease" + [[ "${published_immutable}" == "false" ]] || fail "published rolling release became immutable" + [[ -n "${published_target}" ]] || fail "published rolling release has no target" + [[ -s "${PUBLISHED_BODY}" ]] || fail "published rolling release notes are empty" + if grep -Fq "${SELECTED_TARGET} to ${SELECTED_TARGET}" "${PUBLISHED_BODY}" || \ + grep -Fq "${SELECTED_TARGET}...${SELECTED_TARGET}" "${PUBLISHED_BODY}"; then + fail "published rolling release notes contain a selected-to-selected comparison" + fi + PRESERVE_PUBLISHED_METADATA=true +fi + +FINAL_HIGH_WATER="$(snapshot_public_high_water pre-publication)" +FINAL_ACTION="$(promotion_action_for "${FINAL_HIGH_WATER}")" +if [[ "${FINAL_ACTION}" == "reject" ]]; then + fail "public high-water advanced to ${FINAL_HIGH_WATER} before rolling publication" +fi +[[ "${FINAL_ACTION}" == "repair" || "${FINAL_ACTION}" == "promote" ]] || \ + fail "state module returned an unknown pre-publication promotion action" +require_selected_target_is_current_main "release metadata publication gate" + +NOTES_FILE="${TEMP_DIR}/release-notes.md" +RAW_NOTES_FILE="${TEMP_DIR}/release-notes.raw.md" +if [[ "${PRESERVE_PUBLISHED_METADATA}" == "true" ]]; then + cp "${PUBLISHED_BODY}" "${NOTES_FILE}" +else + GENERATE_NOTES_ARGS=( + "repos/${REPOSITORY}/releases/generate-notes" + -X POST + -f tag_name=rolling-next + -f "target_commitish=${SELECTED_TARGET}" + ) + if [[ "${ROLLING_PUBLISHED_AT_START}" == "true" ]]; then + GENERATE_NOTES_ARGS+=(-f "previous_tag_name=${ROLLING_TAG}") + fi + GENERATE_NOTES_ARGS+=(--jq .body) + "${GH_BIN}" api "${GENERATE_NOTES_ARGS[@]}" > "${RAW_NOTES_FILE}" + node - \ + "${RAW_NOTES_FILE}" \ + "${NOTES_FILE}" \ + "${ROLLING_PUBLISHED_AT_START}" \ + "${ROLLING_TAG}" \ + "${STARTING_REF}" \ + "${SELECTED_TARGET}" <<'NODE' +const fs = require("node:fs"); +const [sourcePath, destinationPath, hadRolling, rollingTag, startingSha, targetSha] = process.argv.slice(2); +let notes = fs.readFileSync(sourcePath, "utf8"); +if (hadRolling === "true") { + const generatedCompare = `/compare/${rollingTag}...rolling-next`; + const immutableCompare = `/compare/${startingSha}...${targetSha}`; + notes = notes.split(generatedCompare).join(immutableCompare); +} +fs.writeFileSync(destinationPath, notes); +NODE +fi + +if [[ "${PRESERVE_PUBLISHED_METADATA}" != "true" ]]; then + require_selected_target_is_current_main "post-notes release metadata publication gate" + "${GH_BIN}" release edit "${ROLLING_TAG}" \ + --repo "${REPOSITORY}" \ + --title "Rolling ${SELECTED_VERSION}" \ + --notes-file "${NOTES_FILE}" \ + --draft=false \ + --latest +fi + +if [[ "${STARTING_REF}" != "${SELECTED_TARGET}" ]]; then + require_selected_target_is_current_main "rolling ref publication gate" + # Rolling is required (lines 597-598) to already exist as a published + # release before this point, so its tag ref is always present; a PATCH + # failure here is a real error and must propagate, never be silently + # retried as "create instead of move" (that masked genuine failures, + # including hard-stop injection, as false success). + "${GH_BIN}" api "repos/${REPOSITORY}/git/refs/tags/${ROLLING_TAG}" \ + -X PATCH \ + -f "sha=${SELECTED_TARGET}" \ + -F force=true >/dev/null +fi + +FINAL_METADATA="${TEMP_DIR}/rolling-final-assets.tsv" +query_assets "${ROLLING_TAG}" "${FINAL_METADATA}" +verify_rolling_aliases "${FINAL_METADATA}" "${TEMP_DIR}/rolling-final-verification" + +FINAL_TITLE="$("${GH_BIN}" release view "${ROLLING_TAG}" --repo "${REPOSITORY}" --json name --jq .name)" +FINAL_RELEASE_LIST="${TEMP_DIR}/final-releases.tsv" +query_releases_paginated "${FINAL_RELEASE_LIST}" +FINAL_RELEASE_ROW="$(awk -F '\t' -v expected="${ROLLING_TAG}" ' + $1 == expected { print; matches += 1 } + END { if (matches != 1) exit 2 } +' "${FINAL_RELEASE_LIST}")" || fail "rolling release was missing or duplicated during final verification" +IFS=$'\t' read -r \ + final_tag \ + FINAL_DRAFT \ + FINAL_PRERELEASE \ + FINAL_IMMUTABLE \ + FINAL_TARGET <<< "${FINAL_RELEASE_ROW}" +FINAL_BODY="${TEMP_DIR}/final-body.md" +"${GH_BIN}" release view "${ROLLING_TAG}" --repo "${REPOSITORY}" --json body --jq .body > "${FINAL_BODY}" +FINAL_REF="$("${GH_BIN}" api "repos/${REPOSITORY}/git/ref/tags/${ROLLING_TAG}" --jq .object.sha)" +[[ "${FINAL_TITLE}" == "Rolling ${SELECTED_VERSION}" ]] || fail "rolling release title did not converge" +[[ "${final_tag}" == "${ROLLING_TAG}" ]] || fail "rolling release resolved to an unexpected tag" +[[ "${FINAL_DRAFT}" == "false" ]] || fail "rolling release is still a draft" +[[ "${FINAL_PRERELEASE}" == "false" ]] || fail "rolling release is still a prerelease" +[[ "${FINAL_IMMUTABLE}" == "false" ]] || fail "rolling release became immutable" +[[ -n "${FINAL_TARGET}" ]] || fail "rolling release has no target" +cmp -s "${FINAL_BODY}" "${NOTES_FILE}" || fail "rolling release notes did not converge" +[[ "${FINAL_REF}" == "${SELECTED_TARGET}" ]] || fail "rolling ref did not converge" + +prune_candidates "${SELECTED_BUILD}" "${SELECTED_TAG}" diff --git a/scripts/release_build_identity.js b/scripts/release_build_identity.js new file mode 100644 index 00000000..71ba9b26 --- /dev/null +++ b/scripts/release_build_identity.js @@ -0,0 +1,43 @@ +"use strict"; + +const CANONICAL_POSITIVE_DECIMAL = /^[1-9][0-9]*$/; + +function assertPositiveDecimal(value, label) { + if (typeof value !== "string" || !CANONICAL_POSITIVE_DECIMAL.test(value)) { + throw new TypeError(`${label} must be a canonical positive decimal string`); + } +} + +function paddedAttempt(value, label) { + assertPositiveDecimal(value, label); + if (BigInt(value) > 999n) { + throw new RangeError(`${label} must be between 1 and 999`); + } + return value.padStart(3, "0"); +} + +function deriveReleaseBuildIdentity({ + eventName, + upstreamRunId, + upstreamRunAttempt, + workflowRunId, + workflowRunAttempt, +}) { + assertPositiveDecimal(workflowRunId, "workflow run id"); + const downstreamAttempt = paddedAttempt(workflowRunAttempt, "workflow run attempt"); + + if (eventName === "workflow_run") { + assertPositiveDecimal(upstreamRunId, "upstream run id"); + const upstreamAttempt = paddedAttempt(upstreamRunAttempt, "upstream run attempt"); + return `${upstreamRunId}${upstreamAttempt}${downstreamAttempt}`; + } + if (eventName === "push") { + return `${workflowRunId}001001`; + } + if (eventName === "workflow_dispatch") { + return `${workflowRunId}001${downstreamAttempt}`; + } + throw new TypeError(`unsupported release event: ${String(eventName)}`); +} + +module.exports = { deriveReleaseBuildIdentity }; diff --git a/scripts/release_build_identity.test.js b/scripts/release_build_identity.test.js new file mode 100644 index 00000000..12d30352 --- /dev/null +++ b/scripts/release_build_identity.test.js @@ -0,0 +1,141 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Public contract for scripts/release_build_identity.js: +// +// deriveReleaseBuildIdentity({ +// eventName, +// workflowRunId, +// workflowRunAttempt, +// upstreamRunId?, +// upstreamRunAttempt?, +// }) -> canonical positive decimal string +// +// A workflow_run identity is upstream CI run ID + three-digit upstream attempt +// + three-digit downstream release attempt. Push/tag identities use the +// current workflow run ID + fixed source attempt 001 + fixed downstream slot +// 001, so a workflow rerun reuses its immutable artifact identity. Manual +// identities use the current workflow run ID + 001 + the three-digit current +// attempt. IDs remain decimal strings and are never converted through Number. +const { deriveReleaseBuildIdentity } = require("./release_build_identity"); + +test("workflow_run identities remain upstream-bound while downstream rebuilds are distinct", () => { + const base = { + eventName: "workflow_run", + upstreamRunId: "900719925474099312345678901234567890", + upstreamRunAttempt: "17", + workflowRunId: "999999999999999999999999999999999999", + }; + + assert.equal( + deriveReleaseBuildIdentity({ ...base, workflowRunAttempt: "1" }), + "900719925474099312345678901234567890017001", + ); + assert.equal( + deriveReleaseBuildIdentity({ ...base, workflowRunAttempt: "2" }), + "900719925474099312345678901234567890017002", + ); +}); + +test("historical workflow reruns cannot substitute the downstream release run ID", () => { + const identity = deriveReleaseBuildIdentity({ + eventName: "workflow_run", + upstreamRunId: "123456789012345678901234567890", + upstreamRunAttempt: "3", + workflowRunId: "987654321098765432109876543210", + workflowRunAttempt: "4", + }); + + assert.equal(identity, "123456789012345678901234567890003004"); + assert.ok(!identity.startsWith("987654321098765432109876543210")); +}); + +test("push/tag workflow reruns reuse one immutable artifact identity", () => { + const workflowRunId = "900719925474099312345678901234567891"; + const first = deriveReleaseBuildIdentity({ + eventName: "push", + workflowRunId, + workflowRunAttempt: "1", + }); + const rerun = deriveReleaseBuildIdentity({ + eventName: "push", + workflowRunId, + workflowRunAttempt: "2", + }); + + assert.equal(first, `${workflowRunId}001001`); + assert.equal(rerun, first); +}); + +test("manual identities use the current run and current attempt", () => { + assert.equal( + deriveReleaseBuildIdentity({ + eventName: "workflow_dispatch", + workflowRunId: "900719925474099312345678901234567891", + workflowRunAttempt: "9", + }), + "900719925474099312345678901234567891001009", + ); +}); + +test("run IDs are canonical positive decimal strings and stay BigInt-safe", async (t) => { + for (const runId of ["0", "00", "01", "+1", "-1", "1e3", "1.5", "", 42]) { + await t.test(JSON.stringify(runId), () => { + assert.throws( + () => deriveReleaseBuildIdentity({ + eventName: "workflow_dispatch", + workflowRunId: runId, + workflowRunAttempt: "1", + }), + /run|id|canonical|positive|decimal|string/i, + ); + }); + } +}); + +test("source and workflow attempts must be decimal integers from 1 through 999", async (t) => { + for (const attempt of ["0", "000", "1000", "1.0", "+1", " 1", "", 1]) { + await t.test(`workflow ${JSON.stringify(attempt)}`, () => { + assert.throws( + () => deriveReleaseBuildIdentity({ + eventName: "workflow_dispatch", + workflowRunId: "41", + workflowRunAttempt: attempt, + }), + /attempt|integer|decimal|1|999|string/i, + ); + }); + } + + for (const attempt of ["0", "1000", "01", "1.0", 1]) { + await t.test(`upstream ${JSON.stringify(attempt)}`, () => { + assert.throws( + () => deriveReleaseBuildIdentity({ + eventName: "workflow_run", + upstreamRunId: "41", + upstreamRunAttempt: attempt, + workflowRunId: "99", + workflowRunAttempt: "1", + }), + /upstream|attempt|integer|decimal|1|999|string/i, + ); + }); + } +}); + +test("unsupported events and incomplete workflow_run inputs fail closed", () => { + assert.throws( + () => deriveReleaseBuildIdentity({ eventName: "schedule", workflowRunId: "41", workflowRunAttempt: "1" }), + /event|unsupported/i, + ); + assert.throws( + () => deriveReleaseBuildIdentity({ + eventName: "workflow_run", + workflowRunId: "41", + workflowRunAttempt: "1", + }), + /upstream|run|attempt|required/i, + ); +}); diff --git a/scripts/restore_release_candidate.sh b/scripts/restore_release_candidate.sh new file mode 100755 index 00000000..5ca19a7c --- /dev/null +++ b/scripts/restore_release_candidate.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +set -euo pipefail + +umask 077 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATE_MODULE="${SCRIPT_DIR}/rolling_release_state.js" +MILESTONE_MODULE="${SCRIPT_DIR}/milestone_payload.js" +SEAL_NAME="programa-release-candidate.json" +GH_COMMAND="${GH_BIN:-gh}" +REPOSITORY="${GITHUB_REPOSITORY:-}" +CANDIDATE_PREFIX="" +DESTINATION_TAG="" +TARGET_SHA="" +BUILD="" +VERSION="" +OUTPUT_DIR="" +TEMP_DIR="" + +fail() { + echo "restore_release_candidate.sh: $*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Usage: restore_release_candidate.sh \ + --candidate-prefix milestone-candidate- \ + --destination-tag vMAJOR.MINOR.PATCH \ + --target-sha <40-lowercase-hex> \ + --build \ + --version \ + --output-dir +EOF +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi + exit "${status}" +} + +file_size() { + stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1" +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + fail "neither shasum nor sha256sum is available" + fi +} + +while (($#)); do + case "$1" in + --candidate-prefix|--destination-tag|--target-sha|--build|--version|--output-dir) + (($# >= 2)) || { usage; fail "$1 requires a value"; } + case "$1" in + --candidate-prefix) [[ -z "${CANDIDATE_PREFIX}" ]] || fail "--candidate-prefix may be supplied only once"; CANDIDATE_PREFIX="$2" ;; + --destination-tag) [[ -z "${DESTINATION_TAG}" ]] || fail "--destination-tag may be supplied only once"; DESTINATION_TAG="$2" ;; + --target-sha) [[ -z "${TARGET_SHA}" ]] || fail "--target-sha may be supplied only once"; TARGET_SHA="$2" ;; + --build) [[ -z "${BUILD}" ]] || fail "--build may be supplied only once"; BUILD="$2" ;; + --version) [[ -z "${VERSION}" ]] || fail "--version may be supplied only once"; VERSION="$2" ;; + --output-dir) [[ -z "${OUTPUT_DIR}" ]] || fail "--output-dir may be supplied only once"; OUTPUT_DIR="$2" ;; + esac + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + fail "unknown argument: $1" + ;; + esac +done + +[[ "${REPOSITORY}" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail "GITHUB_REPOSITORY must be owner/repository" +[[ "${CANDIDATE_PREFIX}" == "milestone-candidate-" ]] || fail "milestone restore requires milestone-candidate-" +[[ "${DESTINATION_TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail "destination tag must be canonical vMAJOR.MINOR.PATCH" +[[ "${VERSION}" == "${DESTINATION_TAG#v}" ]] || fail "version must equal the destination tag semver" +[[ "${TARGET_SHA}" =~ ^[0-9a-f]{40}$ ]] || fail "target SHA must be 40 lowercase hexadecimal characters" +[[ "${BUILD}" =~ ^[1-9][0-9]*$ ]] || fail "build must be a canonical positive decimal string" +[[ -d "${OUTPUT_DIR}" && ! -L "${OUTPUT_DIR}" ]] || fail "output path must be a real directory" +[[ -z "$(find "${OUTPUT_DIR}" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail "output directory must be empty" +[[ -r "${STATE_MODULE}" && -r "${MILESTONE_MODULE}" ]] || fail "release validation modules are unavailable" +GH_COMMAND="$(command -v "${GH_COMMAND}")" || fail "GitHub CLI command is unavailable" +command -v node >/dev/null 2>&1 || fail "node is required" + +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-restore-candidate.XXXXXX")" +trap cleanup EXIT INT TERM +RELEASES_TSV="${TEMP_DIR}/releases.tsv" +"${GH_COMMAND}" api --paginate \ + "repos/${REPOSITORY}/releases?per_page=100" \ + --jq '.[] | [.tag_name, .draft] | @tsv' > "${RELEASES_TSV}" + +release_value() { + local tag="$1" field="$2" + "${GH_COMMAND}" release view "${tag}" --repo "${REPOSITORY}" --json "${field}" --jq ".${field}" +} + +query_assets() { + local tag="$1" output="$2" + "${GH_COMMAND}" release view "${tag}" \ + --repo "${REPOSITORY}" \ + --json assets \ + --jq '.assets[] | [.id, .name, .state, (.size | tostring), (.digest // "-")] | @tsv' > "${output}" +} + +asset_metadata_line() { + local wanted="$1" metadata="$2" + awk -F '\t' -v expected="${wanted}" ' + $2 == expected { print; matches += 1 } + END { if (matches > 1) exit 2 } + ' "${metadata}" +} + +verify_asset() { + local tag="$1" name="$2" expected_size="$3" expected_sha="$4" metadata="$5" destination="$6" + local line asset_id stored_name state size digest extra observed_size observed_sha + line="$(asset_metadata_line "${name}" "${metadata}")" || fail "candidate ${tag} has duplicate asset metadata for ${name}" + [[ -n "${line}" ]] || fail "sealed candidate ${tag} is missing ${name}" + IFS=$'\t' read -r asset_id stored_name state size digest extra <<< "${line}" + [[ -z "${extra:-}" && -n "${asset_id}" && "${stored_name}" == "${name}" ]] || fail "candidate ${tag}/${name} has malformed metadata" + [[ "${state}" == "uploaded" && "${size}" =~ ^[1-9][0-9]*$ ]] || fail "candidate ${tag}/${name} is not completely uploaded" + if [[ -n "${expected_size}" ]]; then + [[ "${size}" == "${expected_size}" ]] || fail "candidate ${tag}/${name} has conflicting size" + fi + if [[ -n "${digest}" && "${digest}" != "-" && "${digest}" != "null" ]]; then + [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "candidate ${tag}/${name} has malformed digest metadata" + if [[ -n "${expected_sha}" ]]; then + [[ "${digest}" == "sha256:${expected_sha}" ]] || fail "candidate ${tag}/${name} has conflicting digest" + fi + fi + mkdir -p "${destination}" + rm -f "${destination}/${name}" + "${GH_COMMAND}" release download "${tag}" --repo "${REPOSITORY}" --pattern "${name}" --dir "${destination}" || \ + fail "could not download candidate ${tag}/${name}" + [[ -f "${destination}/${name}" && ! -L "${destination}/${name}" ]] || fail "candidate download omitted ${tag}/${name}" + observed_size="$(file_size "${destination}/${name}")" + observed_sha="$(sha256_file "${destination}/${name}")" + [[ "${observed_size}" == "${size}" ]] || fail "downloaded candidate ${tag}/${name} disagrees with remote size" + if [[ -n "${digest}" && "${digest}" != "-" && "${digest}" != "null" ]]; then + [[ "${digest}" == "sha256:${observed_sha}" ]] || fail "downloaded candidate ${tag}/${name} disagrees with remote digest" + fi + if [[ -n "${expected_size}" ]]; then + [[ "${observed_size}" == "${expected_size}" && "${observed_sha}" == "${expected_sha}" ]] || \ + fail "downloaded candidate ${tag}/${name} disagrees with its seal" + fi +} + +attest_file() { + local file="$1" + "${GH_COMMAND}" attestation verify "${file}" \ + --repo "${REPOSITORY}" \ + --signer-workflow "${REPOSITORY}/.github/workflows/release.yml" \ + --source-ref "refs/tags/${DESTINATION_TAG}" \ + --source-digest "${TARGET_SHA}" \ + --deny-self-hosted-runners +} + +SEALED_COUNT=0 +SELECTED_PAYLOAD="" +while IFS=$'\t' read -r tag is_draft _is_latest; do + [[ "${tag}" =~ ^${CANDIDATE_PREFIX}${BUILD}-[0-9]{3}$ ]] || continue + [[ "${is_draft}" == "true" ]] || fail "milestone candidate ${tag} must remain a draft" + candidate_dir="${TEMP_DIR}/candidate-${tag##*-}" + metadata="${candidate_dir}/assets.tsv" + seal_dir="${candidate_dir}/seal" + payload_dir="${candidate_dir}/payload" + mkdir -p "${candidate_dir}" + query_assets "${tag}" "${metadata}" + seal_line="$(asset_metadata_line "${SEAL_NAME}" "${metadata}")" || fail "candidate ${tag} has duplicate seal assets" + [[ -n "${seal_line}" ]] || continue + + SEALED_COUNT=$((SEALED_COUNT + 1)) + verify_asset "${tag}" "${SEAL_NAME}" "" "" "${metadata}" "${seal_dir}" + normalized="${candidate_dir}/manifest.json" + node - "${STATE_MODULE}" "${seal_dir}/${SEAL_NAME}" "${TARGET_SHA}" "${VERSION}" "${BUILD}" > "${normalized}" <<'NODE' +"use strict"; +const fs = require("node:fs"); +const [modulePath, sealPath, targetSha, version, build] = process.argv.slice(2); +const { validateCandidateManifest } = require(modulePath); +const manifest = validateCandidateManifest(JSON.parse(fs.readFileSync(sealPath, "utf8"))); +if (!manifest.sealed) throw new TypeError("candidate seal must declare sealed: true"); +if (manifest.targetSha !== targetSha || manifest.version !== version || manifest.build !== build) { + throw new TypeError("candidate seal identity does not match the requested milestone"); +} +process.stdout.write(`${JSON.stringify(manifest)}\n`); +NODE + + [[ "$(release_value "${tag}" tagName)" == "${tag}" ]] || fail "candidate ${tag} tag metadata conflicts" + [[ "$(release_value "${tag}" isDraft)" == "true" ]] || fail "candidate ${tag} is not a draft" + [[ "$(release_value "${tag}" isImmutable)" == "false" ]] || fail "candidate ${tag} draft unexpectedly reports immutable" + [[ "$(release_value "${tag}" targetCommitish)" == "${TARGET_SHA}" ]] || fail "candidate ${tag} target conflicts" + [[ "$(release_value "${tag}" name)" == "Candidate ${BUILD}" ]] || fail "candidate ${tag} title conflicts" + [[ "$(release_value "${tag}" body)" == "candidate" ]] || fail "candidate ${tag} notes conflict" + + expected_tsv="${candidate_dir}/expected.tsv" + node - "${normalized}" > "${expected_tsv}" <<'NODE' +const manifest = require(process.argv[2]); +for (const asset of manifest.assets) { + process.stdout.write(`${asset.name}\t${asset.size}\t${asset.sha256}\n`); +} +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" + + while IFS=$'\t' read -r name size sha extra; do + [[ -n "${name}" && -z "${extra:-}" ]] || fail "candidate ${tag} seal produced malformed asset metadata" + verify_asset "${tag}" "${name}" "${size}" "${sha}" "${metadata}" "${payload_dir}" + done < "${expected_tsv}" + + node - "${STATE_MODULE}" "${normalized}" "${payload_dir}/appcast.xml" \ + "${payload_dir}/programad-remote-manifest-${BUILD}.json" "${REPOSITORY}" "${DESTINATION_TAG}" <<'NODE' +const fs = require("node:fs"); +const [modulePath, manifestPath, appcastPath, daemonPath, 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), +}); +NODE + + attest_file "${seal_dir}/${SEAL_NAME}" + while IFS=$'\t' read -r name _size _sha _extra; do + attest_file "${payload_dir}/${name}" + done < "${expected_tsv}" + SELECTED_PAYLOAD="${payload_dir}" +done < "${RELEASES_TSV}" + +if ((SEALED_COUNT == 0)); then + echo "restore_release_candidate.sh: no sealed milestone candidate exists for build ${BUILD}" >&2 + exit 3 +fi +((SEALED_COUNT == 1)) || fail "duplicate sealed milestone candidate identities exist for build ${BUILD}" +[[ -n "${SELECTED_PAYLOAD}" ]] || fail "sealed milestone candidate was not selected" + +while IFS= read -r name; do + cp "${SELECTED_PAYLOAD}/${name}" "${OUTPUT_DIR}/${name}" +done < <(find "${SELECTED_PAYLOAD}" -mindepth 1 -maxdepth 1 -type f -exec basename {} \; | LC_ALL=C sort) +node - "${MILESTONE_MODULE}" "${OUTPUT_DIR}" "${BUILD}" <<'NODE' +const [modulePath, directory, build] = process.argv.slice(2); +const { verifyMilestonePayload, writeMilestoneManifest } = require(modulePath); +writeMilestoneManifest({ directory, build }); +verifyMilestonePayload({ directory, build }); +NODE + +echo "Restored sealed milestone candidate for ${DESTINATION_TAG} build ${BUILD}." diff --git a/scripts/rolling_release_state.js b/scripts/rolling_release_state.js new file mode 100644 index 00000000..ddf5be11 --- /dev/null +++ b/scripts/rolling_release_state.js @@ -0,0 +1,756 @@ +"use strict"; + +const ROOT_FIELDS = ["schemaVersion", "sealed", "targetSha", "version", "build", "assets"]; +const ASSET_FIELDS = ["name", "role", "size", "sha256"]; +const ROLES = new Set(["immutable", "appcast", "stable-alias"]); +const CANONICAL_BUILD = /^[1-9][0-9]*$/; +const TARGET_SHA = /^[0-9a-f]{40}$/; +const ASSET_SHA = /^[0-9a-f]{64}$/; +const SAFE_BASENAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SPARKLE_NAMESPACE = "http://www.andymatuschak.org/xml-namespaces/sparkle"; +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +function isPlainObject(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function assertPlainObject(value, label) { + if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain JSON object`); +} + +function assertExactFields(value, expected, label) { + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expected.length || + keys.some((key) => typeof key !== "string" || !expected.includes(key)) + ) { + throw new TypeError(`${label} fields must be exactly: ${expected.join(", ")}`); + } + + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) { + throw new TypeError(`${label} field ${key} must be a JSON data field`); + } + } +} + +function assertCanonicalBuild(build, label = "build") { + if (typeof build !== "string" || !CANONICAL_BUILD.test(build)) { + throw new TypeError(`${label} must be a canonical positive decimal string`); + } +} + +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`, + ]; +} + +function validateAsset(asset, index) { + const label = `manifest asset ${index}`; + assertPlainObject(asset, label); + assertExactFields(asset, ASSET_FIELDS, label); + + if ( + typeof asset.name !== "string" || + !SAFE_BASENAME.test(asset.name) || + asset.name === "." || + asset.name === ".." + ) { + throw new TypeError(`${label} name must be a safe basename`); + } + if (!ROLES.has(asset.role)) throw new TypeError(`${label} role is invalid`); + if (!Number.isSafeInteger(asset.size) || asset.size <= 0) { + throw new TypeError(`${label} size must be a positive safe integer`); + } + if (typeof asset.sha256 !== "string" || !ASSET_SHA.test(asset.sha256)) { + throw new TypeError(`${label} sha256 must be 64 lowercase hexadecimal characters`); + } + + return { + name: asset.name, + role: asset.role, + size: asset.size, + sha256: asset.sha256, + }; +} + +function validateCandidateManifest(manifest) { + assertPlainObject(manifest, "manifest"); + assertExactFields(manifest, ROOT_FIELDS, "manifest"); + + if (manifest.schemaVersion !== 1) throw new TypeError("manifest schemaVersion must be 1"); + if (typeof manifest.sealed !== "boolean") throw new TypeError("manifest sealed must be boolean"); + if (typeof manifest.targetSha !== "string" || !TARGET_SHA.test(manifest.targetSha)) { + throw new TypeError("manifest targetSha must be 40 lowercase hexadecimal characters"); + } + assertCanonicalBuild(manifest.build, "manifest build"); + + if (typeof manifest.version !== "string") { + throw new TypeError("manifest version must be a canonical major.minor.patch string"); + } + if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(manifest.version)) { + throw new TypeError("manifest version must be a canonical major.minor.patch string"); + } + if (!Array.isArray(manifest.assets)) throw new TypeError("manifest assets must be an array"); + + const assets = manifest.assets.map(validateAsset); + const names = new Set(); + for (const asset of assets) { + if (names.has(asset.name)) throw new TypeError(`manifest has duplicate asset name: ${asset.name}`); + names.add(asset.name); + } + + const appcast = assets.filter((asset) => asset.role === "appcast"); + const stableAliases = assets.filter((asset) => asset.role === "stable-alias"); + const immutable = assets.filter((asset) => asset.role === "immutable"); + const requiredNames = new Set(requiredImmutableNames(manifest.build)); + + for (const asset of assets) { + if (asset.name === "appcast.xml" && asset.role !== "appcast") { + throw new TypeError("appcast.xml must have the appcast role"); + } + if (asset.name === "programa-macos.dmg" && asset.role !== "stable-alias") { + throw new TypeError("programa-macos.dmg must have the stable-alias role"); + } + if (asset.role === "appcast" && asset.name !== "appcast.xml") { + throw new TypeError("the appcast role is reserved for appcast.xml"); + } + if (asset.role === "stable-alias" && asset.name !== "programa-macos.dmg") { + throw new TypeError("the stable-alias role is reserved for programa-macos.dmg"); + } + if (asset.role === "immutable" && !requiredNames.has(asset.name)) { + throw new TypeError(`manifest has an unexpected immutable asset or build suffix: ${asset.name}`); + } + } + + if (manifest.sealed) { + if (assets.length !== 10) throw new TypeError("sealed manifest must contain exactly 10 assets"); + if (appcast.length !== 1 || stableAliases.length !== 1) { + throw new TypeError("sealed manifest must contain exactly one appcast and one stable alias"); + } + + for (const asset of immutable) { + requiredNames.delete(asset.name); + } + if (requiredNames.size !== 0) { + throw new TypeError( + `sealed manifest is missing required immutable asset: ${requiredNames.values().next().value}`, + ); + } + + const stableDMG = stableAliases[0]; + const immutableDMG = assets.find( + (asset) => asset.name === `programa-macos-${manifest.build}.dmg`, + ); + if ( + !immutableDMG || + stableDMG.size !== immutableDMG.size || + stableDMG.sha256 !== immutableDMG.sha256 + ) { + throw new TypeError("stable DMG must be byte-identical to the immutable build DMG"); + } + } + + return { + schemaVersion: 1, + sealed: manifest.sealed, + targetSha: manifest.targetSha, + version: manifest.version, + build: manifest.build, + assets, + }; +} + +function selectPromotionCandidate(candidates) { + if (!Array.isArray(candidates)) throw new TypeError("candidates must be an array"); + + let selected = null; + for (const candidate of candidates) { + if (!isPlainObject(candidate) || candidate.sealed !== true) continue; + let validated; + try { + validated = validateCandidateManifest(candidate); + } catch (error) { + throw new TypeError(`sealed candidate is invalid: ${error.message}`, { cause: error }); + } + if (selected === null || BigInt(validated.build) > BigInt(selected.build)) selected = validated; + } + return selected; +} + +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) { + if (typeof name !== "string") return null; + for (const pattern of VERSIONED_ASSET_PATTERNS) { + const match = pattern.exec(name); + if (match) return match[1]; + } + return null; +} + +function parseTagAttributes(tag, tagName, label) { + const closingLength = /\/\s*>$/.test(tag) ? 2 : 1; + let source = tag.slice(tagName.length + 1, -closingLength); + const attributes = Object.create(null); + + while (source.length > 0) { + if (/^\s*$/.test(source)) break; + const match = /^\s+([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(source); + if (!match) throw new TypeError(`${label} contains malformed XML attributes`); + const name = match[1]; + if (Object.hasOwn(attributes, name)) { + throw new TypeError(`${label} contains a duplicate XML attribute: ${name}`); + } + attributes[name] = match[2] ?? match[3]; + source = source.slice(match[0].length); + } + return attributes; +} + +function parseAppcastEnclosures( + xml, + label = "appcast", + { allowLegacyEnclosureVersion = false, requireSingleItem = false } = {}, +) { + if (typeof xml !== "string" || xml.trim() === "") { + throw new TypeError(`${label} XML must be a non-empty string`); + } + + const stack = []; + const enclosures = []; + let root = null; + let channelCount = 0; + let itemCount = 0; + let currentItem = null; + let cursor = 0; + + while (cursor < xml.length) { + const start = xml.indexOf("<", cursor); + const text = start === -1 ? xml.slice(cursor) : xml.slice(cursor, start); + if (start === -1) { + if (stack.length === 0 && text.trim() !== "") { + throw new TypeError(`${label} XML has text outside its root element`); + } + if (stack.at(-1)?.name === "sparkle:version") stack.at(-1).text += text; + cursor = xml.length; + break; + } + if (stack.length === 0 && text.trim() !== "") { + throw new TypeError(`${label} XML has text outside its root element`); + } + if (stack.at(-1)?.name === "sparkle:version") stack.at(-1).text += text; + + if (xml.startsWith("", start + 4); + if (end === -1) throw new TypeError(`${label} XML has an unterminated comment`); + cursor = end + 3; + continue; + } + if (xml.startsWith("", start + 9); + if (end === -1) throw new TypeError(`${label} XML has an unterminated CDATA section`); + if (stack.length === 0 && xml.slice(start + 9, end).trim() !== "") { + throw new TypeError(`${label} XML has CDATA outside its root element`); + } + cursor = end + 3; + continue; + } + if (xml.startsWith("", start + 2); + if (end === -1) throw new TypeError(`${label} XML has an unterminated declaration`); + cursor = end + 2; + continue; + } + if (xml.startsWith("", start + 1); + if (end === -1) throw new TypeError(`${label} XML has an unterminated tag`); + const tag = xml.slice(start, end + 1); + const closing = /^<\/([A-Za-z_][A-Za-z0-9_.:-]*)\s*>$/.exec(tag); + if (closing) { + const entry = stack.pop(); + if (entry?.name !== closing[1]) { + throw new TypeError(`${label} XML has mismatched tags`); + } + if (entry.name === "sparkle:version") { + const version = entry.text.trim(); + if (!CANONICAL_BUILD.test(version)) { + throw new TypeError(`${label} item version must be a canonical positive decimal build`); + } + currentItem.version = version; + } else if (entry.name === "item") { + if (currentItem.enclosure === null) { + throw new TypeError(`${label} item must contain exactly one enclosure child`); + } + const legacyVersion = currentItem.enclosure["sparkle:version"]; + if (currentItem.version !== null && legacyVersion !== undefined) { + throw new TypeError( + `${label} item must not contain both child and enclosure-attribute versions`, + ); + } + if (currentItem.version === null) { + if (!allowLegacyEnclosureVersion || legacyVersion === undefined) { + throw new TypeError(`${label} item must contain exactly one sparkle:version child`); + } + if (!CANONICAL_BUILD.test(legacyVersion)) { + throw new TypeError( + `${label} enclosure version must be a canonical positive decimal build`, + ); + } + currentItem.version = legacyVersion; + } + currentItem.enclosure["sparkle:version"] = currentItem.version; + enclosures.push(currentItem.enclosure); + currentItem = null; + } + cursor = end + 1; + continue; + } + + const opening = /^<([A-Za-z_][A-Za-z0-9_.:-]*)(?:\s[^<>]*)?\/?>$/.exec(tag); + if (!opening) throw new TypeError(`${label} XML has a malformed tag`); + const name = opening[1]; + const attributes = parseTagAttributes(tag, name, label); + const selfClosing = /\/\s*>$/.test(tag); + const parent = stack.at(-1)?.name ?? null; + const inheritedSparkleNamespace = stack.at(-1)?.sparkleNamespace ?? null; + const declaresSparkleNamespace = Object.hasOwn(attributes, "xmlns:sparkle"); + if (stack.length > 0 && declaresSparkleNamespace) { + throw new TypeError(`${label} XML must not rebind the Sparkle namespace below rss`); + } + const sparkleNamespace = declaresSparkleNamespace + ? attributes["xmlns:sparkle"] + : inheritedSparkleNamespace; + + if (stack.length === 0) { + if (root !== null) throw new TypeError(`${label} XML has multiple root elements`); + root = name; + if (name === "rss" && attributes["xmlns:sparkle"] !== SPARKLE_NAMESPACE) { + throw new TypeError(`${label} XML must declare the canonical Sparkle namespace`); + } + } + if ( + (name.startsWith("sparkle:") || + Reflect.ownKeys(attributes).some( + (attribute) => attribute !== "xmlns:sparkle" && attribute.startsWith("sparkle:"), + )) && + sparkleNamespace !== SPARKLE_NAMESPACE + ) { + throw new TypeError(`${label} Sparkle fields require the canonical Sparkle namespace`); + } + if (name === "channel") { + if (parent !== "rss" || selfClosing) { + throw new TypeError(`${label} channel must be one non-empty direct child of rss`); + } + channelCount += 1; + if (channelCount > 1) throw new TypeError(`${label} XML must contain exactly one channel`); + } + if ( + Object.hasOwn(attributes, "sparkle:version") && + !( + allowLegacyEnclosureVersion && + name === "enclosure" && + parent === "item" && + currentItem !== null + ) + ) { + throw new TypeError(`${label} versions must be item children, not attributes`); + } + if (name === "item") { + if (parent !== "channel" || currentItem !== null || selfClosing) { + throw new TypeError(`${label} item must be a non-empty child of channel`); + } + itemCount += 1; + if (requireSingleItem && itemCount > 1) { + throw new TypeError(`${label} must contain exactly one direct item`); + } + currentItem = { versionSeen: false, version: null, enclosure: null }; + } else if (name === "sparkle:version") { + if (parent !== "item" || currentItem === null) { + throw new TypeError(`${label} sparkle:version must be a direct item child`); + } + if (currentItem.versionSeen) { + throw new TypeError(`${label} item contains duplicate sparkle:version children`); + } + if (Reflect.ownKeys(attributes).length !== 0 || selfClosing) { + throw new TypeError(`${label} sparkle:version must contain one canonical build`); + } + currentItem.versionSeen = true; + } else if (name === "enclosure") { + if (parent !== "item" || currentItem === null) { + throw new TypeError(`${label} enclosure must be a direct item child`); + } + if (currentItem.enclosure !== null) { + throw new TypeError(`${label} item contains duplicate enclosure children`); + } + currentItem.enclosure = attributes; + } else if (parent === "sparkle:version") { + throw new TypeError(`${label} sparkle:version must contain only a canonical build`); + } + if (!selfClosing) { + stack.push({ + name, + text: name === "sparkle:version" ? "" : null, + sparkleNamespace, + }); + } + cursor = end + 1; + } + + if (stack.length !== 0) throw new TypeError(`${label} XML has unclosed tags`); + if (root !== "rss" || channelCount !== 1) { + throw new TypeError(`${label} XML must contain one direct channel under its rss root`); + } + if (requireSingleItem && itemCount !== 1) { + throw new TypeError(`${label} must contain exactly one direct item`); + } + return enclosures; +} + +function buildsFromAppcast(xml, label) { + const builds = []; + const enclosures = parseAppcastEnclosures(xml, label, { + allowLegacyEnclosureVersion: true, + }); + if (enclosures.length === 0) throw new TypeError(`${label} contains no Sparkle enclosure`); + for (const enclosure of enclosures) { + const version = enclosure["sparkle:version"]; + const urlText = enclosure.url; + if (typeof version !== "string" || typeof urlText !== "string") { + throw new TypeError(`${label} enclosure must contain url and sparkle:version attributes`); + } + + let url; + try { + url = new URL(urlText); + } catch (error) { + throw new TypeError(`${label} enclosure contains an invalid URL`, { cause: error }); + } + if (!CANONICAL_BUILD.test(version)) { + throw new TypeError(`${label} sparkle:version must be a canonical positive decimal build`); + } + const basename = url.pathname.slice(url.pathname.lastIndexOf("/") + 1); + const versionedName = /^programa-macos-([1-9][0-9]*)\.dmg$/.exec(basename); + if (basename !== "programa-macos.dmg" && versionedName?.[1] !== version) { + throw new TypeError( + `${label} enclosure URL must use the legacy DMG name or match sparkle:version`, + ); + } + builds.push(version); + } + return builds; +} + +function derivePublicHighWater(state) { + if (state === null || typeof state !== "object" || Array.isArray(state)) { + throw new TypeError("public state must be an object"); + } + + const builds = []; + if (Array.isArray(state.rollingAssetNames)) { + for (const name of state.rollingAssetNames) { + const build = buildFromAssetName(name); + if (build !== null) builds.push(build); + } + } + const milestoneAppcasts = state.publishedMilestoneAppcastXmls ?? []; + if (!Array.isArray(milestoneAppcasts)) { + throw new TypeError("published milestone appcasts must be an array"); + } + const appcasts = [[state.rollingAppcastXml, "rolling appcast"]]; + for (const [index, xml] of milestoneAppcasts.entries()) { + appcasts.push([xml, `published milestone appcast ${index + 1}`]); + } + for (const [xml, label] of appcasts) { + if (xml !== null && xml !== undefined) builds.push(...buildsFromAppcast(xml, label)); + } + + let highWater = null; + for (const build of builds) { + if (highWater === null || BigInt(build) > BigInt(highWater)) highWater = build; + } + return highWater; +} + +function assertSafeReleaseLocation(repository, tag) { + if ( + typeof repository !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repository) + ) { + throw new TypeError("repository must be a safe owner/name value"); + } + if (typeof tag !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tag)) { + throw new TypeError("release tag must be safe"); + } +} + +function assertExactGitHubURL(value, expected, label) { + if (typeof value !== "string") throw new TypeError(`${label} must be a URL string`); + let parsed; + try { + parsed = new URL(value); + } catch (error) { + throw new TypeError(`${label} must be a valid URL`, { cause: error }); + } + if ( + value !== expected || + parsed.href !== expected || + parsed.protocol !== "https:" || + parsed.hostname !== "github.com" || + parsed.port !== "" || + parsed.username !== "" || + parsed.password !== "" || + parsed.search !== "" || + parsed.hash !== "" + ) { + throw new TypeError(`${label} must exactly reference ${expected}`); + } +} + +function requireImmutableAsset(assetsByName, name, label) { + const asset = assetsByName.get(name); + if (!asset || asset.role !== "immutable") { + throw new TypeError(`${label} must reference a sealed immutable asset: ${name}`); + } + return asset; +} + +function validateReleasePayloadReferences({ + appcastXml, + daemonManifestJson, + repository, + tag, + manifest, +}) { + const normalizedManifest = validateCandidateManifest(manifest); + if (!normalizedManifest.sealed) throw new TypeError("release payload manifest must be sealed"); + assertSafeReleaseLocation(repository, tag); + + const assetsByName = new Map(normalizedManifest.assets.map((asset) => [asset.name, asset])); + const releaseURL = `https://github.com/${repository}/releases/download/${tag}`; + const enclosureName = `programa-macos-${normalizedManifest.build}.dmg`; + const enclosureURL = `${releaseURL}/${enclosureName}`; + const enclosureAsset = requireImmutableAsset(assetsByName, enclosureName, "appcast enclosure"); + + const enclosures = parseAppcastEnclosures(appcastXml, "candidate appcast", { + allowLegacyEnclosureVersion: false, + requireSingleItem: true, + }); + if (enclosures.length !== 1) { + throw new TypeError("candidate appcast must contain exactly one enclosure"); + } + const enclosure = enclosures[0]; + if (enclosure["sparkle:version"] !== normalizedManifest.build) { + throw new TypeError("candidate appcast sparkle:version must equal the manifest build"); + } + assertExactGitHubURL(enclosure.url, enclosureURL, "candidate appcast enclosure URL"); + if (enclosure.length !== String(enclosureAsset.size)) { + throw new TypeError("candidate appcast enclosure length must equal the sealed DMG size"); + } + const signature = enclosure["sparkle:edSignature"]; + const decodedSignature = + typeof signature === "string" && CANONICAL_BASE64.test(signature) + ? Buffer.from(signature, "base64") + : null; + if ( + decodedSignature === null || + decodedSignature.length !== 64 || + decodedSignature.toString("base64") !== signature + ) { + throw new TypeError( + "candidate appcast enclosure signature must be canonical base64 encoding exactly 64 bytes", + ); + } + + 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, + }, + }; +} + +function assertCandidateMayPromote(candidate, highWater) { + const validated = validateCandidateManifest(candidate); + if (!validated.sealed) throw new TypeError("promotion candidate must be sealed"); + if (highWater === null || highWater === undefined) return "promote"; + assertCanonicalBuild(highWater, "public high-water build"); + + const candidateBuild = BigInt(validated.build); + const publicBuild = BigInt(highWater); + if (candidateBuild < publicBuild) { + throw new RangeError( + `candidate build ${validated.build} is below public high-water build ${highWater}`, + ); + } + return candidateBuild === publicBuild ? "repair" : "promote"; +} + +function compareAssets(left, right) { + const roleOrder = { immutable: 0, appcast: 1, "stable-alias": 2 }; + const roleDifference = roleOrder[left.role] - roleOrder[right.role]; + if (roleDifference !== 0) return roleDifference; + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; +} + +function getPromotionOrder(manifest) { + const validated = validateCandidateManifest(manifest); + if (!validated.sealed) throw new TypeError("promotion manifest must be sealed"); + return [...validated.assets].sort(compareAssets); +} + +function createCandidateManifest(input) { + assertPlainObject(input, "candidate manifest input"); + const allowedFields = new Set(ROOT_FIELDS); + for (const key of Reflect.ownKeys(input)) { + if (typeof key !== "string" || !allowedFields.has(key)) { + throw new TypeError(`candidate manifest input has an unknown field: ${String(key)}`); + } + } + + const candidate = validateCandidateManifest({ + schemaVersion: 1, + sealed: true, + targetSha: input.targetSha, + version: input.version, + build: input.build, + assets: input.assets, + }); + candidate.assets.sort(compareAssets); + return candidate; +} + +module.exports = { + validateCandidateManifest, + selectPromotionCandidate, + derivePublicHighWater, + validateReleasePayloadReferences, + assertCandidateMayPromote, + getPromotionOrder, + createCandidateManifest, + parseAppcastEnclosures, + buildsFromAppcast, +}; diff --git a/scripts/rolling_release_state.test.js b/scripts/rolling_release_state.test.js new file mode 100644 index 00000000..8f65625b --- /dev/null +++ b/scripts/rolling_release_state.test.js @@ -0,0 +1,687 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Public contract for scripts/rolling_release_state.js: +// +// validateCandidateManifest(manifest) -> normalizedManifest +// selectPromotionCandidate(candidates) -> normalizedManifest | null +// derivePublicHighWater(state) -> canonical build string | null +// assertCandidateMayPromote(candidate, highWater) -> "repair" | "promote" +// getPromotionOrder(manifest) -> asset[] +// createCandidateManifest(input) -> normalizedManifest +// +// A manifest is a JSON-derived plain object with this exact shape: +// +// { +// schemaVersion: 1, +// sealed: boolean, +// targetSha: <40 lowercase hexadecimal characters>, +// version: , +// build: , +// assets: [{ name, role, size, sha256 }] +// } +// +// `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 +// 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 +// agrees with `build`. +// +// Candidate selection accepts manifest-like JSON values. It ignores unsealed +// drafts, validates every sealed candidate, and returns the sealed manifest with +// the greatest build. Public state has `{ rollingAssetNames, +// rollingAppcastXml, publishedMilestoneAppcastXmls }`; null/absent appcasts are +// allowed, but every advertised milestone appcast must be supplied and any +// non-null malformed appcast fails closed. Every valid enclosure contributes +// to the maximum alongside versioned asset names. +// Build comparisons use BigInt internally and returned builds remain strings. +const { + assertCandidateMayPromote, + createCandidateManifest, + derivePublicHighWater, + getPromotionOrder, + selectPromotionCandidate, + validateCandidateManifest, + validateReleasePayloadReferences, +} = require("./rolling_release_state"); + +const TARGET_SHA = "1".repeat(40); +const ASSET_SHA = "a".repeat(64); +const VALID_ED25519_SIGNATURE = Buffer.alloc(64, 1).toString("base64"); + +function versionFor() { + return "0.64.73"; +} + +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", + size: index + 1, + sha256: String(index + 1).padStart(64, "0"), + })); + assets[0].size = 902; + assets[0].sha256 = "c".repeat(64); + return assets; +} + +function manifestFor(build = "900719925474099312345678901234567890", overrides = {}) { + const manifest = { + schemaVersion: 1, + sealed: true, + targetSha: TARGET_SHA, + version: versionFor(build), + build, + assets: [ + ...requiredAssets(build), + { name: "appcast.xml", role: "appcast", size: 901, sha256: "b".repeat(64) }, + { name: "programa-macos.dmg", role: "stable-alias", size: 902, sha256: "c".repeat(64) }, + ], + }; + + return { ...manifest, ...overrides }; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function appcast(build, enclosureBuild = build, length = 902, signature = VALID_ED25519_SIGNATURE, tag = "rolling") { + return ` + + + ${build} + + + `; +} + +function legacyAppcast(build, length = 902, signature = VALID_ED25519_SIGNATURE) { + return ` + + + ${build} + + + `; +} + +function legacyAttributeAppcast(build, enclosureBuild = build) { + return ` + + + + + `; +} + +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, + }); +} + +test("a complete sealed manifest preserves arbitrary-precision canonical build values", () => { + const input = clone(manifestFor()); + const validated = validateCandidateManifest(input); + + assert.deepEqual(validated, input); + assert.equal(typeof validated.build, "string"); + assert.equal(validated.build, "900719925474099312345678901234567890"); +}); + +test("the candidate schema rejects unknown, missing, or non-JSON field values", async (t) => { + const cases = [ + ["unknown root field", { ...manifestFor("41"), unexpected: true }], + ["unknown asset field", (() => { + const value = manifestFor("41"); + value.assets[0].unexpected = true; + return value; + })()], + ["missing target SHA", (() => { + const value = manifestFor("41"); + delete value.targetSha; + return value; + })()], + ["wrong schema version", manifestFor("41", { schemaVersion: 2 })], + ["non-array assets", manifestFor("41", { assets: {} })], + ["numeric build", manifestFor("41", { build: 41 })], + ["non-JSON build", manifestFor("41", { build: 41n })], + ]; + + for (const [name, value] of cases) { + await t.test(name, () => { + assert.throws(() => validateCandidateManifest(value), /manifest|schema|field|build|asset|json/i); + }); + } +}); + +test("builds must be canonical positive decimal strings", async (t) => { + for (const build of ["0", "00", "01", "+1", "-1", "1.0", "1e3", " 1", "1 ", ""] ) { + await t.test(JSON.stringify(build), () => { + assert.throws( + () => validateCandidateManifest(manifestFor("41", { build })), + /build|canonical|decimal|positive/i, + ); + }); + } +}); + +test("target SHA and marketing version are independently canonical", async (t) => { + const cases = [ + ["uppercase target SHA", { targetSha: TARGET_SHA.toUpperCase().replaceAll("1", "A") }], + ["short target SHA", { targetSha: "1".repeat(39) }], + ["incomplete marketing version", { version: "0.64" }], + ["noncanonical marketing version", { version: "0.64.073" }], + ]; + + for (const [name, override] of cases) { + await t.test(name, () => { + assert.throws(() => validateCandidateManifest(manifestFor("41", override)), /sha|version|build|target/i); + }); + } +}); + +test("marketing version does not have to contain the monotonic build", () => { + const build = "900719925474099312345678901234567890"; + const value = manifestFor(build, { version: "0.64.73" }); + + assert.deepEqual(validateCandidateManifest(clone(value)), value); +}); + +test("asset names are safe unique basenames and roles are exact", async (t) => { + const mutations = [ + ["parent traversal", (asset) => { asset.name = "../appcast.xml"; }], + ["nested path", (asset) => { asset.name = "nested/appcast.xml"; }], + ["Windows path", (asset) => { asset.name = "nested\\appcast.xml"; }], + ["empty name", (asset) => { asset.name = ""; }], + ["unknown role", (asset) => { asset.role = "alias"; }], + ]; + + for (const [name, mutate] of mutations) { + await t.test(name, () => { + const value = manifestFor("41"); + mutate(value.assets[8]); + 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; + assert.throws(() => validateCandidateManifest(value), /duplicate|asset|name/i); + }); +}); + +test("mutable aliases cannot masquerade as immutable payloads", async (t) => { + for (const alias of ["appcast.xml", "programa-macos.dmg"]) { + await t.test(alias, () => { + const value = manifestFor("41"); + value.assets.find((asset) => asset.name === alias).role = "immutable"; + assert.throws(() => validateCandidateManifest(value), /alias|role|immutable|appcast/i); + }); + } +}); + +test("a sealed candidate has exactly one appcast and one stable alias", async (t) => { + const cases = [ + ["missing appcast", (assets) => assets.filter((asset) => asset.role !== "appcast")], + ["missing stable alias", (assets) => assets.filter((asset) => asset.role !== "stable-alias")], + ["second appcast", (assets) => [...assets, { name: "feed.xml", role: "appcast", size: 1, sha256: ASSET_SHA }]], + ["second stable alias", (assets) => [...assets, { name: "latest.dmg", role: "stable-alias", size: 1, sha256: ASSET_SHA }]], + ]; + + for (const [name, mutate] of cases) { + await t.test(name, () => { + const value = manifestFor("41"); + value.assets = mutate(value.assets); + assert.throws(() => validateCandidateManifest(value), /appcast|stable|alias|exactly|asset/i); + }); + } +}); + +test("a sealed candidate requires every build-specific immutable payload", async (t) => { + for (const required of requiredAssets("41").map((asset) => asset.name)) { + await t.test(required, () => { + const value = manifestFor("41"); + value.assets = value.assets.filter((asset) => asset.name !== required); + assert.throws(() => validateCandidateManifest(value), /missing|required|asset|immutable/i); + }); + } +}); + +test("immutable payload suffixes must match the candidate build", () => { + const value = manifestFor("41"); + value.assets[0].name = "programa-macos-40.dmg"; + + assert.throws(() => validateCandidateManifest(value), /build|suffix|asset|required/i); +}); + +test("the stable DMG is byte-identical to the immutable build DMG", () => { + const valid = manifestFor("41"); + assert.doesNotThrow(() => validateCandidateManifest(valid)); + + for (const field of ["size", "sha256"]) { + const mismatched = manifestFor("41"); + const stable = mismatched.assets.find((asset) => asset.name === "programa-macos.dmg"); + stable[field] = field === "size" ? stable.size + 1 : "d".repeat(64); + assert.throws(() => validateCandidateManifest(mismatched), /stable|dmg|identical|size|sha|hash/i); + } +}); + +test("the appcast authenticates the exact immutable DMG bytes", async (t) => { + const manifest = manifestFor("41"); + assert.doesNotThrow(() => validateReferences(manifest, appcast("41"))); + + await t.test("enclosure length differs from sealed DMG", () => { + assert.throws(() => validateReferences(manifest, appcast("41", "41", 901)), /length|size|dmg|enclosure/i); + }); + for (const signature of [ + "", + "not base64", + "YWJjZA", + Buffer.alloc(63, 1).toString("base64"), + Buffer.alloc(65, 1).toString("base64"), + ]) { + await t.test(`invalid signature ${JSON.stringify(signature)}`, () => { + assert.throws( + () => validateReferences(manifest, appcast("41", "41", 902, signature)), + /signature|base64|canonical|empty|64|length/i, + ); + }); + } +}); + +test("every payload has a positive safe integer size and lowercase SHA-256", async (t) => { + for (const size of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, "1"]) { + await t.test(`size ${String(size)}`, () => { + const value = manifestFor("41"); + value.assets[0].size = size; + assert.throws(() => validateCandidateManifest(value), /size|integer|positive|safe/i); + }); + } + + for (const sha256 of ["a".repeat(63), "A".repeat(64), "g".repeat(64), 123]) { + await t.test(`sha256 ${String(sha256).slice(0, 8)}`, () => { + const value = manifestFor("41"); + value.assets[0].sha256 = sha256; + assert.throws(() => validateCandidateManifest(value), /sha|hash|lowercase|hex/i); + }); + } +}); + +test("candidate selection returns the highest sealed build regardless of input order", () => { + const low = manifestFor("900719925474099312345678901234567890"); + const high = manifestFor("900719925474099312345678901234567892"); + const middle = manifestFor("900719925474099312345678901234567891"); + + assert.equal(selectPromotionCandidate([middle, high, low]).build, high.build); + assert.equal(selectPromotionCandidate([high, low, middle]).build, high.build); +}); + +test("candidate selection ignores incomplete unsealed drafts", () => { + const incompleteDraft = { schemaVersion: 1, sealed: false, build: "999999999999999999999999999999999999" }; + const sealed = manifestFor("42"); + + assert.deepEqual(selectPromotionCandidate([incompleteDraft, sealed]), sealed); + assert.equal(selectPromotionCandidate([incompleteDraft]), null); +}); + +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")); + + assert.throws( + () => selectPromotionCandidate([validLower, corruptHigher]), + /candidate|sealed|missing|required|asset/i, + ); +}); + +test("public high-water is the maximum build from rolling assets and readable appcasts", () => { + const state = { + rollingAssetNames: [ + "programa-macos-900719925474099312345678901234567891.dmg", + "programad-remote-linux-arm64-900719925474099312345678901234567893", + "programa-macos.dmg", + "appcast.xml", + ], + rollingAppcastXml: appcast("900719925474099312345678901234567892"), + publishedMilestoneAppcastXmls: [appcast("900719925474099312345678901234567890")], + }; + + assert.equal(derivePublicHighWater(state), "900719925474099312345678901234567893"); +}); + +test("missing or stale rolling aliases cannot lower the recoverable public high-water", async (t) => { + const immutableBuild = "900719925474099312345678901234567899"; + const rollingAssetNames = [`programa-macos-${immutableBuild}.dmg`]; + + await t.test("aliases missing", () => { + assert.equal( + derivePublicHighWater({ rollingAssetNames, rollingAppcastXml: null, publishedMilestoneAppcastXmls: [] }), + immutableBuild, + ); + }); + + await t.test("rolling appcast points at an older enclosure", () => { + assert.equal( + derivePublicHighWater({ + rollingAssetNames: [...rollingAssetNames, "appcast.xml", "wrong-stable-name.dmg"], + rollingAppcastXml: appcast("2"), + publishedMilestoneAppcastXmls: [appcast("3")], + }), + immutableBuild, + ); + }); + + await t.test("an advertised rolling appcast is malformed", () => { + assert.throws( + () => derivePublicHighWater({ + rollingAssetNames, + rollingAppcastXml: "", + publishedMilestoneAppcastXmls: [appcast("3")], + }), + /appcast|xml|malformed|enclosure/i, + ); + }); +}); + +test("every valid appcast enclosure contributes to the public high-water", () => { + const xml = ` + 41 + 900719925474099312345678901234567899 + 73 + `; + + assert.equal( + derivePublicHighWater({ rollingAssetNames: [], rollingAppcastXml: xml, publishedMilestoneAppcastXmls: [] }), + "900719925474099312345678901234567899", + ); +}); + +test("legacy milestone appcasts with a stable DMG URL still contribute canonical high-water evidence", () => { + assert.equal( + derivePublicHighWater({ + rollingAssetNames: [], + rollingAppcastXml: null, + publishedMilestoneAppcastXmls: [legacyAppcast("900719925474099312345678901234567899")], + }), + "900719925474099312345678901234567899", + ); +}); + +test("official attribute-era Sparkle appcasts contribute canonical public high-water evidence", () => { + assert.equal( + derivePublicHighWater({ + rollingAssetNames: [], + rollingAppcastXml: legacyAttributeAppcast("900719925474099312345678901234567899"), + publishedMilestoneAppcastXmls: [], + }), + "900719925474099312345678901234567899", + ); +}); + +test("attribute-era versions are accepted only as one direct enclosure attribute", async (t) => { + const feed = (item) => `${item}`; + const baseAttributes = `url="https://github.com/darkroomengineering/programa/releases/download/v0.1.0/programa-macos-41.dmg" length="902" sparkle:edSignature="${VALID_ED25519_SIGNATURE}"`; + const cases = [ + ["child and matching attribute", `41`], + ["child and disagreeing attribute", `41`], + ["duplicate attributes", ``], + ["attribute on item", ``], + ["attribute on nested enclosure", ``], + ]; + for (const [name, item] of cases) { + await t.test(name, () => { + assert.throws( + () => derivePublicHighWater({ rollingAssetNames: [], rollingAppcastXml: feed(item), publishedMilestoneAppcastXmls: [] }), + /appcast|version|duplicate|direct|item|enclosure|ambiguous/i, + ); + }); + } +}); + +test("candidate appcasts still require the exact build-versioned enclosure URL", () => { + const manifest = manifestFor("41"); + assert.throws( + () => validateReferences(manifest, legacyAppcast("41")), + /candidate|enclosure|url|programa-macos-41|versioned|exact/i, + ); +}); + +test("an archived candidate binds every build-specific URL to its exact permanent tag", () => { + const manifest = manifestFor("41"); + const archiveTag = "rolling-candidate-41"; + + assert.doesNotThrow(() => validateReferences( + manifest, + appcast("41", "41", 902, VALID_ED25519_SIGNATURE, archiveTag), + archiveTag, + )); + assert.throws( + () => validateReleasePayloadReferences({ + appcastXml: appcast("41", "41", 902, VALID_ED25519_SIGNATURE, archiveTag), + daemonManifestJson: daemonManifestFor(manifest, "rolling-candidate-40"), + repository: "darkroomengineering/programa", + tag: archiveTag, + manifest, + }), + /archive|candidate|release|tag|url|rolling-candidate-41|exact/i, + ); +}); + +test("candidate appcasts require the modern child version form", () => { + const manifest = manifestFor("41"); + assert.throws( + () => validateReferences(manifest, legacyAttributeAppcast("41")), + /candidate|child|version|modern|appcast/i, + ); +}); + +test("each appcast item has one canonical child version paired with one matching enclosure", async (t) => { + const feed = (item) => `${item}`; + const enclosure = (build) => ``; + const cases = [ + ["missing child version", `${enclosure("41")}`], + ["duplicate child versions", `4141${enclosure("41")}`], + ["duplicate enclosures", `41${enclosure("41")}${enclosure("41")}`], + ["version outside item", `41${enclosure("41")}`], + ["mismatched enclosure build", `41${enclosure("42")}`], + ]; + + for (const [name, item] of cases) { + await t.test(name, () => { + assert.throws( + () => derivePublicHighWater({ + rollingAssetNames: [], + rollingAppcastXml: feed(item), + publishedMilestoneAppcastXmls: [], + }), + /appcast|item|version|enclosure|ambiguous|match/i, + ); + }); + } +}); + +test("Sparkle-prefixed fields require the canonical Sparkle namespace", async (t) => { + for (const [name, namespace] of [ + ["missing namespace", ""], + ["wrong namespace", ' xmlns:sparkle="urn:not-sparkle"'], + ]) { + await t.test(name, () => { + const xml = appcast("41").replace( + ' xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"', + namespace, + ); + assert.throws( + () => derivePublicHighWater({ rollingAssetNames: [], rollingAppcastXml: xml, publishedMilestoneAppcastXmls: [] }), + /namespace|xmlns|sparkle|appcast/i, + ); + }); + } +}); + +test("candidate appcasts require one exact direct rss channel item hierarchy", async (t) => { + const manifest = manifestFor("41"); + const enclosure = ``; + const item = `41${enclosure}`; + const cases = [ + ["descendant namespace rebind", `41${enclosure}`], + ["nested channel", `${item}`], + ["multiple channels", `${item}`], + ["item outside channel", `${item}`], + ["nested item", `${item}`], + ]; + for (const [name, xml] of cases) { + await t.test(name, () => { + assert.throws(() => validateReferences(manifest, xml), /candidate|rss|channel|item|direct|namespace|structure/i); + }); + } +}); + +test("a malformed advertised milestone appcast fails closed", () => { + assert.throws( + () => derivePublicHighWater({ + rollingAssetNames: ["programa-macos-42.dmg"], + rollingAppcastXml: null, + publishedMilestoneAppcastXmls: [appcast("43"), ""], + }), + /appcast|xml|malformed|enclosure/i, + ); +}); + +test("an advertised appcast without a Sparkle enclosure fails closed", () => { + const emptyFeed = ` + + Programa updatesNo download + `; + + assert.throws( + () => derivePublicHighWater({ + rollingAssetNames: ["programa-macos-42.dmg"], + rollingAppcastXml: emptyFeed, + publishedMilestoneAppcastXmls: [], + }), + /appcast|malformed|enclosure/i, + ); +}); + +test("every published milestone appcast contributes even when semantic tag order disagrees with build order", () => { + assert.equal( + derivePublicHighWater({ + rollingAssetNames: [], + rollingAppcastXml: null, + publishedMilestoneAppcastXmls: [ + appcast("900719925474099312345678901234567899"), + appcast("41"), + appcast("73"), + ], + }), + "900719925474099312345678901234567899", + ); +}); + +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"], + rollingAppcastXml: null, + publishedMilestoneAppcastXmls: [], + }), + null, + ); +}); + +test("an advertised appcast with a noncanonical child version fails closed", () => { + assert.throws( + () => derivePublicHighWater({ + rollingAssetNames: [], + rollingAppcastXml: appcast("0042", "0042"), + publishedMilestoneAppcastXmls: [], + }), + /appcast|version|canonical|decimal|build/i, + ); +}); + +test("promotion rejects downgrades, permits equal-build repair, and identifies newer promotion", () => { + const highWater = "900719925474099312345678901234567891"; + + assert.throws( + () => assertCandidateMayPromote(manifestFor("900719925474099312345678901234567890"), highWater), + /below|older|downgrade|high.water|build/i, + ); + assert.equal(assertCandidateMayPromote(manifestFor(highWater), highWater), "repair"); + assert.equal( + assertCandidateMayPromote(manifestFor("900719925474099312345678901234567892"), highWater), + "promote", + ); +}); + +test("promotion order is deterministic: immutable prerequisites, appcast, then stable alias", () => { + const value = manifestFor("41"); + value.assets.reverse(); + + const ordered = getPromotionOrder(value); + const names = ordered.map((asset) => asset.name); + const immutableNames = requiredAssets("41").map((asset) => asset.name).sort(); + + assert.deepEqual(names, [...immutableNames, "appcast.xml", "programa-macos.dmg"]); + assert.ok(ordered.slice(0, -2).every((asset) => asset.role === "immutable")); + assert.equal(ordered.at(-2).role, "appcast"); + assert.equal(ordered.at(-1).role, "stable-alias"); +}); + +test("manifest creation adds the schema version and canonicalizes asset order", () => { + const source = manifestFor("41"); + source.assets.reverse(); + delete source.schemaVersion; + + const created = createCandidateManifest(source); + + assert.equal(created.schemaVersion, 1); + assert.equal(created.build, "41"); + assert.deepEqual(created.assets, getPromotionOrder(created)); + assert.deepEqual(validateCandidateManifest(clone(created)), created); +}); diff --git a/scripts/sparkle_monotonic_guard.js b/scripts/sparkle_monotonic_guard.js new file mode 100644 index 00000000..dceddb2d --- /dev/null +++ b/scripts/sparkle_monotonic_guard.js @@ -0,0 +1,90 @@ +"use strict"; + +const { buildsFromAppcast } = require("./rolling_release_state"); + +const CANONICAL_BUILD = /^[1-9][0-9]*$/; + +function assertCanonicalBuild(build, label) { + if (typeof build !== "string" || !CANONICAL_BUILD.test(build)) { + throw new TypeError(`${label} must be a canonical positive decimal build string`); + } +} + +function decodeAssetBytes(data) { + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + } + throw new TypeError("appcast asset download did not return bytes"); +} + +async function enforceSparkleMonotonicBuild({ github, owner, repo, effectiveBuild }) { + assertCanonicalBuild(effectiveBuild, "effective build"); + if ( + github === null || + typeof github !== "object" || + typeof github.rest?.repos?.listReleases !== "function" || + typeof github.paginate?.iterator !== "function" || + typeof github.rest?.repos?.getReleaseAsset !== "function" + ) { + throw new TypeError("github must be an authenticated Octokit-compatible client"); + } + if (typeof owner !== "string" || owner === "" || typeof repo !== "string" || repo === "") { + throw new TypeError("owner and repo are required"); + } + + const publishedBuilds = []; + let advertisedAppcastCount = 0; + const pages = github.paginate.iterator(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + for await (const page of pages) { + if (!Array.isArray(page?.data)) throw new TypeError("release enumeration returned invalid data"); + for (const release of page.data) { + const tag = release?.tag_name; + if (release?.draft === true) continue; + if (release?.draft !== false) { + throw new TypeError(`release ${String(tag)} has an invalid draft state`); + } + if (!Array.isArray(release.assets)) { + throw new TypeError(`published release ${tag} assets are unavailable`); + } + const appcasts = release.assets.filter((asset) => asset?.name === "appcast.xml"); + if (appcasts.length === 0) continue; + if (appcasts.length !== 1) { + throw new TypeError(`published release ${tag} must contain at most one appcast.xml asset`); + } + advertisedAppcastCount += 1; + const appcast = appcasts[0]; + if (!Number.isSafeInteger(appcast.id) || appcast.id <= 0) { + throw new TypeError(`authoritative release ${tag} appcast asset id is invalid`); + } + const response = await github.rest.repos.getReleaseAsset({ + owner, + repo, + asset_id: appcast.id, + headers: { accept: "application/octet-stream" }, + }); + const xml = decodeAssetBytes(response?.data); + publishedBuilds.push(...buildsFromAppcast(xml, `${tag} appcast`)); + } + } + + if (advertisedAppcastCount === 0) return; + if (publishedBuilds.length === 0) { + throw new TypeError("authoritative releases contain no published Sparkle builds"); + } + let publishedHighWater = publishedBuilds[0]; + for (const build of publishedBuilds.slice(1)) { + if (BigInt(build) > BigInt(publishedHighWater)) publishedHighWater = build; + } + if (BigInt(effectiveBuild) <= BigInt(publishedHighWater)) { + throw new RangeError( + `effective build ${effectiveBuild} must be greater than published build ${publishedHighWater}`, + ); + } +} + +module.exports = { enforceSparkleMonotonicBuild }; diff --git a/scripts/sparkle_monotonic_guard.test.js b/scripts/sparkle_monotonic_guard.test.js new file mode 100644 index 00000000..71a9e50e --- /dev/null +++ b/scripts/sparkle_monotonic_guard.test.js @@ -0,0 +1,258 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Public contract for scripts/sparkle_monotonic_guard.js: +// +// await enforceSparkleMonotonicBuild({ github, owner, repo, effectiveBuild }) +// +// The authenticated Octokit client is paginated through +// `github.paginate.iterator(github.rest.repos.listReleases, ...)`. Every +// published, non-draft release that advertises appcast.xml is authoritative, +// regardless of its tag. The guard downloads and parses each advertised appcast, +// rejects incomplete or ambiguous release state, and compares the candidate +// against the BigInt maximum. Drafts and releases without appcasts are ignored. +// Bootstrap succeeds only when no authoritative advertised appcast exists. +const { enforceSparkleMonotonicBuild } = require("./sparkle_monotonic_guard"); + +const OWNER = "darkroomengineering"; +const REPO = "programa"; +const VALID_ED25519_SIGNATURE = Buffer.alloc(64, 1).toString("base64"); + +function appcast(build, enclosureBuild = build, namespace = "http://www.andymatuschak.org/xml-namespaces/sparkle") { + return ` + + ${build} + + `; +} + +function legacyAppcast(build) { + return ` + + ${build} + + `; +} + +function legacyAttributeAppcast(build, enclosureBuild = build) { + return ` + + + `; +} + +function octokitDownload(xml) { + const bytes = new TextEncoder().encode(xml); + return { data: bytes.buffer, status: 200 }; +} + +function apiError(message, status) { + const error = new Error(message); + if (status !== undefined) error.status = status; + return error; +} + +function release(tag, assetId, { draft = false, assets, xml = appcast(String(assetId)) } = {}) { + return { + release: { + id: assetId + 1000, + tag_name: tag, + draft, + assets: assets ?? [{ id: assetId, name: "appcast.xml" }], + }, + xml, + }; +} + +function githubWith({ pages = [], listError, downloadErrors = new Map() } = {}) { + const xmlByAsset = new Map(); + const normalizedPages = pages.map((page) => page.map((entry) => { + if (entry.xml !== undefined) { + for (const asset of entry.release.assets) { + if (asset.name === "appcast.xml") xmlByAsset.set(asset.id, entry.xml); + } + } + return entry.release; + })); + const downloaded = []; + let pagesRead = 0; + const listReleases = async () => { throw new Error("listReleases must be consumed through pagination"); }; + const github = { + rest: { repos: { + listReleases, + async getReleaseAsset({ owner, repo, asset_id: assetId, headers }) { + assert.equal(owner, OWNER); + assert.equal(repo, REPO); + assert.equal(headers?.accept, "application/octet-stream"); + downloaded.push(assetId); + if (downloadErrors.has(assetId)) throw downloadErrors.get(assetId); + if (!xmlByAsset.has(assetId)) throw apiError("asset unavailable", 404); + return octokitDownload(xmlByAsset.get(assetId)); + }, + } }, + paginate: { + async *iterator(route, params) { + assert.equal(route, listReleases); + assert.deepEqual(params, { owner: OWNER, repo: REPO, per_page: 100 }); + if (listError) throw listError; + for (const page of normalizedPages) { + pagesRead += 1; + yield { data: page }; + } + }, + }, + }; + return { github, downloaded, get pagesRead() { return pagesRead; } }; +} + +function enforce(client, effectiveBuild) { + return enforceSparkleMonotonicBuild({ github: client.github, owner: OWNER, repo: REPO, effectiveBuild }); +} + +test("empty public release state bootstraps the first Sparkle publication", async () => { + const client = githubWith({ pages: [[]] }); + await assert.doesNotReject(enforce(client, "1")); + assert.deepEqual(client.downloaded, []); +}); + +test("drafts and non-draft releases without appcasts do not create authoritative public state", async () => { + const client = githubWith({ pages: [[ + release("rolling-candidate-99", 99, { draft: true }), + release("nightly", 100, { assets: [{ id: 100, name: "programa-macos.dmg" }] }), + release("v01.2.3", 102, { assets: [] }), + release("v1.2.3", 101, { draft: true }), + ]] }); + await assert.doesNotReject(enforce(client, "1")); + assert.deepEqual(client.downloaded, []); +}); + +test("an arbitrary published tag with an appcast contributes to the maximum", async () => { + const higher = "900719925474099312345678901234567899"; + const client = githubWith({ pages: [[ + release("rolling", 10, { xml: appcast("41") }), + release("customer-preview-2026", 11, { xml: appcast(higher) }), + ]] }); + await assert.rejects(enforce(client, higher), /greater|published|build|monotonic/i); + assert.deepEqual(client.downloaded, [10, 11]); +}); + +test("all pages and authoritative releases contribute to the BigInt maximum", async () => { + const huge = "900719925474099312345678901234567890"; + const client = githubWith({ pages: [ + [release("rolling", 10, { xml: appcast("41") }), release("preview", 11, { xml: appcast("999") })], + [release("v0.1.0", 12, { xml: appcast(huge) }), release("v9.0.0", 13, { draft: true, xml: appcast(`${huge}9`) })], + ] }); + await assert.doesNotReject(enforce(client, (BigInt(huge) + 1n).toString())); + assert.equal(client.pagesRead, 2); + assert.deepEqual(client.downloaded, [10, 11, 12]); +}); + +test("legacy milestone stable-DMG appcasts remain monotonic high-water evidence", async () => { + const published = "900719925474099312345678901234567899"; + const client = githubWith({ pages: [[release("v0.1.0", 10, { xml: legacyAppcast(published) })]] }); + await assert.rejects(enforce(client, published), /greater|published|build|monotonic/i); + assert.deepEqual(client.downloaded, [10]); +}); + +test("official attribute-era appcasts remain monotonic high-water evidence", async () => { + const published = "900719925474099312345678901234567899"; + const client = githubWith({ pages: [[release("legacy-channel", 10, { xml: legacyAttributeAppcast(published) })]] }); + await assert.rejects(enforce(client, published), /greater|published|build|monotonic/i); +}); + +test("an equal or lower candidate cannot pass the maximum from any page", async () => { + const client = githubWith({ pages: [ + [release("rolling", 10, { xml: appcast("41") })], + [release("v1.0.0", 11, { xml: appcast("900719925474099312345678901234567899") })], + ] }); + await assert.rejects(enforce(client, "900719925474099312345678901234567899"), /greater|published|build|monotonic/i); +}); + +test("release enumeration failures cannot silently disable downgrade protection", async () => { + for (const error of [apiError("server", 500), apiError("rate limit", 403), new Error("network")]) { + const client = githubWith({ listError: error }); + await assert.rejects(enforce(client, "2")); + } +}); + +test("every advertised appcast is unique and readable", async (t) => { + await t.test("missing appcast is ignored", async () => { + const client = githubWith({ pages: [[release("rolling", 10, { assets: [{ id: 9, name: "programa-macos.dmg" }] })]] }); + await assert.doesNotReject(enforce(client, "42")); + assert.deepEqual(client.downloaded, []); + }); + await t.test("duplicate appcast", async () => { + const client = githubWith({ pages: [[release("rolling", 10, { assets: [ + { id: 10, name: "appcast.xml" }, { id: 11, name: "appcast.xml" }, + ] })]] }); + await assert.rejects(enforce(client, "42"), /appcast|duplicate|exactly/i); + }); + await t.test("unreadable appcast", async () => { + const client = githubWith({ + pages: [[release("rolling", 10)]], + downloadErrors: new Map([[10, apiError("unavailable", 503)]]), + }); + await assert.rejects(enforce(client, "42"), /unavailable|503|appcast/i); + }); + await t.test("malformed appcast", async () => { + const client = githubWith({ pages: [[release("rolling", 10, { xml: "" })]] }); + await assert.rejects(enforce(client, "42"), /xml|appcast|malformed|unclosed/i); + }); +}); + +test("Sparkle fields require the canonical namespace URI", async (t) => { + for (const [name, xml] of [ + ["missing", appcast("41").replace(' xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"', "")], + ["wrong", appcast("41", "41", "urn:not-sparkle")], + ]) { + await t.test(name, async () => { + const client = githubWith({ pages: [[release("rolling", 10, { xml })]] }); + await assert.rejects(enforce(client, "42"), /namespace|xmlns|sparkle|appcast/i); + }); + } +}); + +test("one item must pair one canonical child version with one matching enclosure", async (t) => { + const feed = (item) => `${item}`; + const enclosure = (build) => ``; + const cases = [ + ["missing version", `${enclosure("41")}`], + ["duplicate versions", `4141${enclosure("41")}`], + ["duplicate enclosures", `41${enclosure("41")}${enclosure("41")}`], + ["mismatched build", `41${enclosure("42")}`], + ["noncanonical version", `0041${enclosure("0041")}`], + ]; + for (const [name, item] of cases) { + await t.test(name, async () => { + const client = githubWith({ pages: [[release("rolling", 10, { xml: feed(item) })]] }); + await assert.rejects(enforce(client, "42"), /appcast|item|version|enclosure|canonical|match/i); + }); + } +}); + +test("attribute-era versions reject duplicate or indirect version sources", async (t) => { + const feed = (item) => `${item}`; + const baseAttributes = `url="https://github.com/${OWNER}/${REPO}/releases/download/v0.1.0/programa-macos-41.dmg" length="902" sparkle:edSignature="${VALID_ED25519_SIGNATURE}"`; + const cases = [ + ["child and matching attribute", `41`], + ["child and disagreeing attribute", `41`], + ["duplicate attributes", ``], + ["attribute on item", ``], + ["nested enclosure", ``], + ]; + for (const [name, item] of cases) { + await t.test(name, async () => { + const client = githubWith({ pages: [[release("legacy-channel", 10, { xml: feed(item) })]] }); + await assert.rejects(enforce(client, "42"), /appcast|version|duplicate|direct|item|enclosure|ambiguous/i); + }); + } +}); + +test("effective builds are canonical positive decimal strings", async () => { + for (const effectiveBuild of ["0", "01", "+1", "1e3", 42]) { + const client = githubWith({ pages: [[]] }); + await assert.rejects(enforce(client, effectiveBuild), /build|canonical|decimal/i); + } +}); diff --git a/tests/test_ci_change_classification.sh b/tests/test_ci_change_classification.sh new file mode 100755 index 00000000..c29c84c3 --- /dev/null +++ b/tests/test_ci_change_classification.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CLASSIFIER="${REPO_ROOT}/scripts/classify_ci_changes.sh" + +if [[ ! -f "${CLASSIFIER}" ]]; then + echo "FAIL: missing CI change classifier at scripts/classify_ci_changes.sh" >&2 + exit 1 +fi + +run_case() { + local name="$1" + local expected_app="$2" + local expected_daemon="$3" + local changed_paths="$4" + local output + local expected + + if ! output="$(printf '%s' "${changed_paths}" | bash "${CLASSIFIER}")"; then + echo "FAIL: ${name}: classifier exited non-zero" >&2 + exit 1 + fi + + printf -v expected \ + 'run_app_jobs=%s\nrun_remote_daemon_jobs=%s' \ + "${expected_app}" \ + "${expected_daemon}" + + if [[ "${output}" != "${expected}" ]]; then + echo "FAIL: ${name}: unexpected classifier output" >&2 + printf 'expected:\n%s\n' "${expected}" >&2 + printf 'actual:\n%s\n' "${output}" >&2 + exit 1 + 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_ci_create_dmg_pinned.sh b/tests/test_ci_create_dmg_pinned.sh index d4517b13..59c6671d 100755 --- a/tests/test_ci_create_dmg_pinned.sh +++ b/tests/test_ci_create_dmg_pinned.sh @@ -5,39 +5,71 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT -NPM_STUB="$TMP_DIR/npm" -NPM_LOG="$TMP_DIR/npm.log" +BUN_STUB="$TMP_DIR/bun" +BUN_LOG="$TMP_DIR/bun.log" +BUN_CWD="$TMP_DIR/bun.cwd" -cat > "$NPM_STUB" <<'EOF' +cat > "$BUN_STUB" <<'EOF' #!/usr/bin/env bash set -euo pipefail -printf '%s\n' "$@" > "${TEST_NPM_LOG:?}" +printf '%s\n' "$PWD" > "${TEST_BUN_CWD:?}" +printf '%s\n' "$@" > "${TEST_BUN_LOG:?}" EOF -chmod +x "$NPM_STUB" +chmod +x "$BUN_STUB" CREATE_DMG_VERSION=8.0.0 \ -PROGRAMA_NPM_COMMAND="$NPM_STUB" \ -TEST_NPM_LOG="$NPM_LOG" \ +PROGRAMA_BUN_COMMAND="$BUN_STUB" \ +TEST_BUN_LOG="$BUN_LOG" \ +TEST_BUN_CWD="$BUN_CWD" \ "$ROOT_DIR/scripts/install-create-dmg.sh" -EXPECTED_ARGS=$'install\n--global\ncreate-dmg@8.0.0' -if [ "$(cat "$NPM_LOG")" != "$EXPECTED_ARGS" ]; then - echo "FAIL: installer did not pass the explicit create-dmg version to npm" >&2 +BUN_CALL=() +while IFS= read -r argument; do BUN_CALL+=("$argument"); done < "$BUN_LOG" +if [[ " ${BUN_CALL[*]} " != *" install "* || " ${BUN_CALL[*]} " != *" --frozen-lockfile "* || " ${BUN_CALL[*]} " != *" --ignore-scripts "* ]]; then + echo "FAIL: installer did not use a frozen local Bun install with lifecycle scripts disabled" >&2 exit 1 fi - -rm -f "$NPM_LOG" -if CREATE_DMG_VERSION=latest \ - PROGRAMA_NPM_COMMAND="$NPM_STUB" \ - TEST_NPM_LOG="$NPM_LOG" \ - "$ROOT_DIR/scripts/install-create-dmg.sh" >"$TMP_DIR/invalid.out" 2>&1; then - echo "FAIL: installer accepted a non-version create-dmg selector" >&2 +if [[ " ${BUN_CALL[*]} " == *" --global "* || " ${BUN_CALL[*]} " == *" add "* || " ${BUN_CALL[*]} " == *" create-dmg@"* ]]; then + echo "FAIL: installer used global or range-based package resolution" >&2 exit 1 fi -if [ -e "$NPM_LOG" ]; then - echo "FAIL: installer invoked npm before rejecting a non-version selector" >&2 +INSTALL_DIR="$(cat "$BUN_CWD")" +for ((index = 0; index < ${#BUN_CALL[@]}; index += 1)); do + if [[ "${BUN_CALL[index]}" == "--cwd" ]]; then + INSTALL_DIR="${BUN_CALL[index + 1]:-}" + fi +done +[[ -f "${INSTALL_DIR}/package.json" && -f "${INSTALL_DIR}/bun.lock" ]] || { + echo "FAIL: Bun install directory lacks package.json or committed bun.lock" >&2 exit 1 -fi +} +node - "${INSTALL_DIR}/package.json" "${INSTALL_DIR}/bun.lock" 8.0.0 <<'NODE' +const fs = require("node:fs"); +const [packagePath, lockPath, expectedVersion] = process.argv.slice(2); +const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8")); +const declared = packageJson.dependencies?.["create-dmg"] ?? packageJson.devDependencies?.["create-dmg"]; +if (declared !== expectedVersion) process.exit(1); +const lock = fs.readFileSync(lockPath, "utf8"); +if (!lock.includes(`create-dmg@${expectedVersion}`)) process.exit(1); +if (!/sha512-[A-Za-z0-9+/]+={0,2}/.test(lock)) process.exit(1); +NODE + +for invalid_version in latest 8.0.1; do + rm -f "$BUN_LOG" + if CREATE_DMG_VERSION="${invalid_version}" \ + PROGRAMA_BUN_COMMAND="$BUN_STUB" \ + TEST_BUN_LOG="$BUN_LOG" \ + TEST_BUN_CWD="$BUN_CWD" \ + "$ROOT_DIR/scripts/install-create-dmg.sh" >"$TMP_DIR/invalid.out" 2>&1; then + echo "FAIL: installer accepted create-dmg version ${invalid_version}" >&2 + exit 1 + fi + + if [ -e "$BUN_LOG" ]; then + echo "FAIL: installer invoked Bun before rejecting create-dmg version ${invalid_version}" >&2 + exit 1 + fi +done -echo "PASS: create-dmg installer requires and installs an explicit version" +echo "PASS: create-dmg installer uses an integrity-locked local Bun install with scripts disabled" diff --git a/tests/test_ghostty_cache_revision.sh b/tests/test_ghostty_cache_revision.sh new file mode 100755 index 00000000..046be2c8 --- /dev/null +++ b/tests/test_ghostty_cache_revision.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Regression test: cache keys must follow the checked-out Ghostty revision. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$ROOT_DIR/scripts/ghostty_cache_revision.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +REPO_DIR="$TMP_DIR/ghostty" +NESTED_NON_GIT_DIR="$REPO_DIR/ordinary-directory" +NON_GIT_DIR="$TMP_DIR/not-a-repository" +MISSING_DIR="$TMP_DIR/missing" + +git init --object-format=sha1 -q "$REPO_DIR" +git -C "$REPO_DIR" config user.name "Programa Test" +git -C "$REPO_DIR" config user.email "programa-test@example.invalid" + +printf 'first revision\n' > "$REPO_DIR/revision.txt" +git -C "$REPO_DIR" add revision.txt +git -C "$REPO_DIR" commit -q -m "fixture: first revision" + +FIRST_EXPECTED="$(git -C "$REPO_DIR" rev-parse HEAD)" +FIRST_ACTUAL="$("$SCRIPT" "$REPO_DIR")" + +if [[ ! "$FIRST_ACTUAL" =~ ^[0-9a-f]{40}$ ]]; then + echo "FAIL: revision helper did not print exactly one 40-character Git revision" >&2 + exit 1 +fi + +if [ "$FIRST_ACTUAL" != "$FIRST_EXPECTED" ]; then + echo "FAIL: revision helper did not report the first checked-out revision" >&2 + exit 1 +fi + +printf 'second revision\n' >> "$REPO_DIR/revision.txt" +git -C "$REPO_DIR" add revision.txt +git -C "$REPO_DIR" commit -q -m "fixture: second revision" + +SECOND_EXPECTED="$(git -C "$REPO_DIR" rev-parse HEAD)" +SECOND_ACTUAL="$("$SCRIPT" "$REPO_DIR")" + +if [ "$SECOND_ACTUAL" != "$SECOND_EXPECTED" ]; then + echo "FAIL: revision helper did not report the new checked-out revision" >&2 + exit 1 +fi + +if [ "$SECOND_ACTUAL" = "$FIRST_ACTUAL" ]; then + echo "FAIL: revision helper reused the previous revision after a new commit" >&2 + exit 1 +fi + +mkdir -p "$NESTED_NON_GIT_DIR" +if "$SCRIPT" "$NESTED_NON_GIT_DIR" >"$TMP_DIR/nested-non-git.out" 2>&1; then + echo "FAIL: revision helper accepted an ordinary directory nested inside a Git worktree" >&2 + exit 1 +fi + +mkdir -p "$NON_GIT_DIR" +if "$SCRIPT" "$NON_GIT_DIR" >"$TMP_DIR/non-git.out" 2>&1; then + echo "FAIL: revision helper accepted a directory that is not a Git working tree" >&2 + exit 1 +fi + +if "$SCRIPT" "$MISSING_DIR" >"$TMP_DIR/missing.out" 2>&1; then + echo "FAIL: revision helper accepted a missing path" >&2 + exit 1 +fi + +echo "PASS: Ghostty cache revision follows the checked-out commit" diff --git a/tests/test_milestone_release_publication.sh b/tests/test_milestone_release_publication.sh new file mode 100755 index 00000000..ce012a3b --- /dev/null +++ b/tests/test_milestone_release_publication.sh @@ -0,0 +1,423 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Black-box contract for scripts/publish_milestone_release.sh: +# +# GH_BIN=/path/to/gh GITHUB_REPOSITORY=owner/repo \ +# scripts/publish_milestone_release.sh \ +# --tag vX.Y.Z --target-sha <40-lowercase-hex> \ +# --build --payload-dir +# +# External workflow concurrency serializes calls. The helper verifies the local +# milestone manifest before GitHub mutation, then converges one permanent +# release without overwriting conflicting public bytes. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HELPER="${ROOT_DIR}/scripts/publish_milestone_release.sh" +MODULE="${ROOT_DIR}/scripts/milestone_payload.js" +REPOSITORY="darkroomengineering/programa" +TAG="v1.2.3" +BUILD="41" +TARGET_SHA="$(printf '%040x' 41)" +MANIFEST_NAME="programa-milestone-payload.json" +ED25519_SIGNATURE="AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ==" + +[[ -x "${HELPER}" ]] || { echo "FAIL: missing executable milestone publisher" >&2; exit 1; } +[[ -r "${MODULE}" ]] || { echo "FAIL: missing milestone payload module" >&2; exit 1; } + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-milestone-publication.XXXXXX")" +trap 'rm -rf "${TMP_DIR}"' EXIT +STATE_DIR="${TMP_DIR}/state" +FAKE_GH="${TMP_DIR}/gh" +RUN_OUTPUT="${TMP_DIR}/run.out" + +fail() { echo "FAIL: $*" >&2; exit 1; } +file_size() { stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1"; } +sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; } +release_dir() { printf '%s/releases/%s' "${STATE_DIR}" "$1"; } +asset_dir() { printf '%s/assets/%s' "$(release_dir "$1")" "$2"; } + +payload_names() { + local build="$1" + 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 +} + +prepare_payload() { + local directory="$1" build="$2" index=0 name release_url enclosure_size + mkdir -p "${directory}" + while IFS= read -r name; do + printf 'payload-%s-%s\n' "${index}" "${name}" > "${directory}/${name}" + index=$((index + 1)) + done < <(payload_names "${build}") + cp "${directory}/programa-macos-${build}.dmg" "${directory}/programa-macos.dmg" + release_url="https://github.com/${REPOSITORY}/releases/download/${TAG}" + enclosure_size="$(file_size "${directory}/programa-macos-${build}.dmg")" + cat > "${directory}/appcast.xml" < + ${build} + + +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" < "${STATE_DIR}/operations.log" + printf '100\n' > "${STATE_DIR}/next_asset_id" + printf '%s\n' "${TARGET_SHA}" > "${STATE_DIR}/tag_ref" +} + +write_release() { + local tag="$1" target="$2" draft="$3" latest="$4" title="$5" body="$6" dir + dir="$(release_dir "${tag}")"; mkdir -p "${dir}/assets" + printf '%s\n' "${target}" > "${dir}/target" + printf '%s\n' "${draft}" > "${dir}/draft" + [[ "${draft}" == true ]] && printf 'false\n' > "${dir}/immutable" || printf 'true\n' > "${dir}/immutable" + printf '%s\n' "${latest}" > "${dir}/latest" + printf '%s\n' "${title}" > "${dir}/title" + printf '%s\n' "${body}" > "${dir}/body" +} + +write_asset() { + local tag="$1" name="$2" source="$3" dir id + dir="$(asset_dir "${tag}" "${name}")"; mkdir -p "${dir}" + id="$(cat "${STATE_DIR}/next_asset_id")"; printf '%s\n' "$((id + 1))" > "${STATE_DIR}/next_asset_id" + printf '%s\n' "${id}" > "${dir}/id"; cp "${source}" "${dir}/bytes" + printf 'uploaded\n' > "${dir}/state"; file_size "${source}" > "${dir}/size" + printf 'sha256:%s\n' "$(sha256_file "${source}")" > "${dir}/digest" +} + +invoke() { + local payload_dir="$1" stop_after="${2:-}" build="${3:-${BUILD}}" + GH_BIN="${FAKE_GH}" GITHUB_REPOSITORY="${REPOSITORY}" FAKE_GH_STATE_DIR="${STATE_DIR}" \ + FAKE_GH_STOP_AFTER_MUTATION="${stop_after}" FAKE_GH_DUPLICATE_ASSET="${FAKE_GH_DUPLICATE_ASSET:-}" \ + "${HELPER}" --tag "${TAG}" --target-sha "${TARGET_SHA}" --build "${build}" \ + --payload-dir "${payload_dir}" > "${RUN_OUTPUT}" 2>&1 +} + +cat > "${FAKE_GH}" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail +STATE_DIR="${FAKE_GH_STATE_DIR:?}" +RELEASES="${STATE_DIR}/releases" +LOG="${STATE_DIR}/operations.log" +release_dir() { printf '%s/%s' "${RELEASES}" "$1"; } +asset_dir() { printf '%s/assets/%s' "$(release_dir "$1")" "$2"; } +file_size() { stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1"; } +digest_file() { printf 'sha256:%s' "$(shasum -a 256 "$1" | awk '{print $1}')"; } +log() { printf '%s\n' "$*" >> "${LOG}"; } +mutation() { + local count + count="$(( $(cat "${STATE_DIR}/mutation_count" 2>/dev/null || echo 0) + 1 ))" + printf '%s\n' "${count}" > "${STATE_DIR}/mutation_count"; log "mutation $*" + if [[ -n "${FAKE_GH_STOP_AFTER_MUTATION:-}" && "${count}" == "${FAKE_GH_STOP_AFTER_MUTATION}" ]]; then + echo "hard stop after mutation ${count}" >&2; exit 97 + fi +} +next_asset_id() { local id; id="$(cat "${STATE_DIR}/next_asset_id")"; printf '%s\n' "$((id + 1))" > "${STATE_DIR}/next_asset_id"; printf '%s' "${id}"; } + +release_view() { + local tag="$1"; shift; local query="" dir + while (($#)); do case "$1" in --jq) query="$2"; shift 2 ;; --json|--repo) shift 2 ;; *) shift ;; esac; done + dir="$(release_dir "${tag}")"; [[ -d "${dir}" ]] || { echo "release not found" >&2; exit 1; } + log "view-release ${tag} query=${query}" + case "${query}" in + .targetCommitish) cat "${dir}/target" ;; .name) cat "${dir}/title" ;; .body) cat "${dir}/body" ;; + .isDraft) cat "${dir}/draft" ;; .isImmutable) cat "${dir}/immutable" ;; .isLatest) echo "unknown JSON field: isLatest" >&2; exit 2 ;; + *assets*'@tsv'*) + local asset + for asset in "${dir}/assets"/*; do + [[ -d "${asset}" ]] || continue + printf '%s\t%s\t%s\t%s\t%s\n' "$(cat "${asset}/id")" "$(basename "${asset}")" \ + "$(cat "${asset}/state")" "$(cat "${asset}/size")" "$(cat "${asset}/digest")" + if [[ "$(basename "${asset}")" == "${FAKE_GH_DUPLICATE_ASSET:-}" ]]; then + printf '%s\t%s\t%s\t%s\t%s\n' "$(cat "${asset}/id")" "$(basename "${asset}")" \ + "$(cat "${asset}/state")" "$(cat "${asset}/size")" "$(cat "${asset}/digest")" + fi + done | LC_ALL=C sort -k2,2 ;; + *) printf '%s\t%s\t%s\t%s\n' "$(cat "${dir}/target")" "$(cat "${dir}/title")" "$(cat "${dir}/draft")" "$(cat "${dir}/latest")" ;; + esac +} + +release_list() { + local release + while (($#)); do case "$1" in --repo|--limit|--json|--jq) shift 2 ;; *) shift ;; esac; done + for release in "${RELEASES}"/*; do + [[ -d "${release}" ]] || continue + printf '%s\t%s\t%s\n' "$(basename "${release}")" "$(cat "${release}/draft")" "$(cat "${release}/latest")" + done | LC_ALL=C sort -k1,1 +} + +release_create() { + local tag="$1"; shift; local target="" title="" notes="" draft=false generate=false dir + while (($#)); do case "$1" in --target) target="$2"; shift 2 ;; --title) title="$2"; shift 2 ;; + --notes) notes="$2"; shift 2 ;; --notes-file) notes="$(cat "$2")"; shift 2 ;; + --generate-notes) generate=true; shift ;; --draft) draft=true; shift ;; --repo) shift 2 ;; *) shift ;; esac; done + [[ ! -e "$(release_dir "${tag}")" ]] || { echo "release exists" >&2; exit 1; } + [[ "${generate}" == true ]] && notes="generated notes for ${tag}" + dir="$(release_dir "${tag}")"; mkdir -p "${dir}/assets" + printf '%s\n' "${target}" > "${dir}/target"; printf '%s\n' "${title}" > "${dir}/title" + printf '%s\n' "${notes}" > "${dir}/body"; printf '%s\n' "${draft}" > "${dir}/draft"; printf 'false\n' > "${dir}/latest" + printf 'false\n' > "${dir}/immutable" + mutation "create-release ${tag} draft=${draft}" +} + +release_upload() { + local tag="$1"; shift; local file name dir + while (($#)); do case "$1" in --repo) shift 2 ;; --clobber) shift ;; *) file="$1"; shift ;; esac; done + name="$(basename "${file}")"; dir="$(asset_dir "${tag}" "${name}")" + [[ ! -d "${dir}" ]] || { echo "asset exists" >&2; exit 1; } + mkdir -p "${dir}"; next_asset_id > "${dir}/id"; cp "${file}" "${dir}/bytes" + printf 'uploaded\n' > "${dir}/state"; file_size "${file}" > "${dir}/size"; digest_file "${file}" > "${dir}/digest" + mutation "upload-asset ${tag} ${name}" +} + +release_download() { + local tag="$1"; shift; local pattern="" destination="." + while (($#)); do case "$1" in --pattern|-p) pattern="$2"; shift 2 ;; --dir|-D) destination="$2"; shift 2 ;; --repo) shift 2 ;; *) shift ;; esac; done + mkdir -p "${destination}"; cp "$(asset_dir "${tag}" "${pattern}")/bytes" "${destination}/${pattern}" + log "authenticated-download ${tag} ${pattern}" +} + +release_edit() { + local tag="$1"; shift; local dir title="" draft="" latest="" + dir="$(release_dir "${tag}")" + while (($#)); do case "$1" in --title) title="$2"; shift 2 ;; --draft|--draft=false) draft=false; shift ;; --latest|--latest=true) latest=true; shift ;; --repo) shift 2 ;; *) shift ;; esac; done + [[ -z "${title}" ]] || printf '%s\n' "${title}" > "${dir}/title" + [[ -z "${draft}" ]] || printf '%s\n' "${draft}" > "${dir}/draft" + [[ "${draft}" != false ]] || printf 'true\n' > "${dir}/immutable" + if [[ "${latest}" == true ]]; then + local other; for other in "${RELEASES}"/*; do [[ -d "${other}" ]] && printf 'false\n' > "${other}/latest"; done + printf 'true\n' > "${dir}/latest" + fi + mutation "edit-release ${tag} draft=$(cat "${dir}/draft") latest=$(cat "${dir}/latest")" +} + +api_command() { + local endpoint="$1"; shift; local target="" tag="" query="" + while (($#)); do case "$1" in -f|-F) case "$2" in target_commitish=*) target="${2#*=}" ;; tag_name=*) tag="${2#*=}" ;; esac; shift 2 ;; -X|--method) shift 2 ;; --jq) query="$2"; shift 2 ;; *) shift ;; esac; done + case "${endpoint}" in + */git/ref/tags/*) cat "${STATE_DIR}/tag_ref"; log "read-tag-ref $(cat "${STATE_DIR}/tag_ref")" ;; + */releases/generate-notes) printf 'generated notes for %s at %s\n' "${tag}" "${target}" ;; + *) echo "unsupported api ${endpoint}" >&2; exit 2 ;; + esac +} + +command="$1"; shift +case "${command}" in + release) sub="$1"; shift; case "${sub}" in view) release_view "$@" ;; list) release_list "$@" ;; create) release_create "$@" ;; upload) release_upload "$@" ;; download) release_download "$@" ;; edit) release_edit "$@" ;; *) exit 2 ;; esac ;; + api) api_command "$@" ;; *) echo "unsupported gh command ${command}" >&2; exit 2 ;; +esac +FAKE +chmod +x "${FAKE_GH}" + +assert_converged() { + local payload_dir="$1" expected_advisory_target="${2:-${TARGET_SHA}}" name + [[ "$(cat "$(release_dir "${TAG}")/target")" == "${expected_advisory_target}" ]] || fail "advisory targetCommitish changed unexpectedly" + [[ "$(cat "$(release_dir "${TAG}")/title")" == "${TAG}" ]] || fail "title did not converge" + [[ "$(cat "$(release_dir "${TAG}")/draft")" == false ]] || fail "release remains draft" + [[ "$(cat "$(release_dir "${TAG}")/immutable")" == true ]] || fail "published release is not immutable" + [[ "$(cat "$(release_dir "${TAG}")/latest")" == true ]] || fail "release is not latest" + [[ -s "$(release_dir "${TAG}")/body" ]] || fail "generated notes are missing" + 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" +} + +PAYLOAD="${TMP_DIR}/payload-41" +prepare_payload "${PAYLOAD}" "${BUILD}" + +# The immutable tag must still resolve to the requested target before any +# release mutation, even if a matching candidate payload exists locally. +reset_state +printf '%040x\n' 42 > "${STATE_DIR}/tag_ref" +if invoke "${PAYLOAD}"; then fail "publisher accepted a tag ref at another target"; fi +grep -Fq "read-tag-ref $(printf '%040x' 42)" "${STATE_DIR}/operations.log" || fail "publisher did not read the live tag ref" +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "tag-ref mismatch mutated release" + +# GitHub may report the advisory targetCommitish as `main` even though the +# immutable tag ref resolves to the authenticated target SHA. Matching partial +# draft bytes remain resumable in that state. +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}" +: > "${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" + +# Local manifest verification runs before GitHub mutation. +reset_state +cp -R "${PAYLOAD}" "${TMP_DIR}/tampered-local" +printf 'tamper\n' >> "${TMP_DIR}/tampered-local/programa-macos.dmg" +if invoke "${TMP_DIR}/tampered-local"; then fail "tampered local payload passed verification"; fi +[[ ! -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 + semantic_dir="${TMP_DIR}/semantic-${semantic_conflict}" + cp -R "${PAYLOAD}" "${semantic_dir}" + case "${semantic_conflict}" in + appcast-url) + cat > "${semantic_dir}/appcast.xml" < + ${BUILD} + + +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 + if invoke "${semantic_dir}"; then fail "${semantic_conflict} passed semantic validation"; fi + ! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "${semantic_conflict} mutated GitHub" +done + +# Fresh publication converges with aliases last and authenticated verification. +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 -Fq "view-release ${TAG} query=.isImmutable" "${STATE_DIR}/operations.log" || fail "publisher did not require immutable published state" + +# A published exact release is idempotent. +: > "${STATE_DIR}/operations.log"; rm -f "${STATE_DIR}/mutation_count"; invoke "${PAYLOAD}"; assert_converged "${PAYLOAD}" +[[ ! -s "${STATE_DIR}/operations.log" || -z "$(grep '^mutation ' "${STATE_DIR}/operations.log" || true)" ]] || fail "idempotent retry mutated release" + +# A permanent milestone can stop being latest when a newer milestone ships. +# Exact retries accept that state and never make the old release latest again. +printf 'false\n' > "$(release_dir "${TAG}")/latest" +: > "${STATE_DIR}/operations.log"; rm -f "${STATE_DIR}/mutation_count" +invoke "${PAYLOAD}" +[[ "$(cat "$(release_dir "${TAG}")/latest")" == false ]] || fail "published retry made an old milestone latest again" +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "published non-latest retry mutated release" + +# Exact published bytes do not bypass authentication of the immutable tag. +printf '%040x\n' 42 > "${STATE_DIR}/tag_ref" +: > "${STATE_DIR}/operations.log"; rm -f "${STATE_DIR}/mutation_count" +if invoke "${PAYLOAD}"; then fail "published exact retry accepted a moved live tag"; fi +grep -Fq "read-tag-ref $(printf '%040x' 42)" "${STATE_DIR}/operations.log" || fail "published retry did not authenticate the live tag ref" +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "published retry with moved tag mutated release" + +# Hard stop after an early upload leaves a resumable draft. +reset_state +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. +reset_state +if invoke "${PAYLOAD}" 11; 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" +: > "${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" + +# Partial published releases are never repaired in place. +reset_state; write_release "${TAG}" "${TARGET_SHA}" false true "${TAG}" generated +write_asset "${TAG}" "programa-macos-${BUILD}.dmg" "${PAYLOAD}/programa-macos-${BUILD}.dmg" +: > "${STATE_DIR}/operations.log" +if invoke "${PAYLOAD}"; then fail "partial published release was repaired"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "partial published release was mutated" + +# Partial drafts reject unexpected, duplicate, and wrong-byte assets. +for conflict in unexpected duplicate wrong-bytes; do + reset_state; write_release "${TAG}" "${TARGET_SHA}" true false "${TAG}" generated + case "${conflict}" in + unexpected) write_asset "${TAG}" unexpected.bin "${PAYLOAD}/appcast.xml" ;; + duplicate) write_asset "${TAG}" appcast.xml "${PAYLOAD}/appcast.xml" ;; + wrong-bytes) printf 'wrong\n' > "${TMP_DIR}/wrong"; write_asset "${TAG}" appcast.xml "${TMP_DIR}/wrong" ;; + esac + : > "${STATE_DIR}/operations.log" + duplicate_name=""; [[ "${conflict}" != duplicate ]] || duplicate_name=appcast.xml + if FAKE_GH_DUPLICATE_ASSET="${duplicate_name}" invoke "${PAYLOAD}"; then + fail "${conflict} draft state was accepted" + fi + ! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "${conflict} draft state was mutated" +done + +# Same tag with different bytes or build never mutates the permanent release. +reset_state; invoke "${PAYLOAD}"; assert_converged "${PAYLOAD}" +DIFFERENT="${TMP_DIR}/different"; cp -R "${PAYLOAD}" "${DIFFERENT}" +rm -f "${DIFFERENT}/${MANIFEST_NAME}" +printf 'different\n' > "${DIFFERENT}/appcast.xml" +node - "${MODULE}" "${DIFFERENT}" "${BUILD}" <<'NODE' +const [modulePath, directory, build] = process.argv.slice(2); +require(modulePath).writeMilestoneManifest({ directory, build }); +NODE +: > "${STATE_DIR}/operations.log" +if invoke "${DIFFERENT}"; then fail "same tag accepted different payload bytes"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "different-byte retry mutated release" + +PAYLOAD_42="${TMP_DIR}/payload-42"; prepare_payload "${PAYLOAD_42}" 42 +: > "${STATE_DIR}/operations.log" +if invoke "${PAYLOAD_42}" '' 42; then fail "same tag accepted a different build"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "different-build retry mutated release" + +echo "PASS: milestone publication is exact, permanent, and retry-convergent" diff --git a/tests/test_rolling_release_publication.sh b/tests/test_rolling_release_publication.sh new file mode 100755 index 00000000..51431841 --- /dev/null +++ b/tests/test_rolling_release_publication.sh @@ -0,0 +1,1358 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Black-box contracts exercised by this harness: +# +# GH_BIN=/path/to/gh GITHUB_REPOSITORY=owner/repo \ +# scripts/publish_release_candidate.sh \ +# --candidate-tag rolling-candidate- \ +# --target-sha --build --version \ +# --seal-output \ +# --asset-role = [--asset-role ...] +# +# 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 +# prepared seal last. This two-phase contract applies to rolling and milestone +# candidates through the same CLI. +# +# The candidate publisher creates or resumes a draft release at the requested +# tag. It uploads only absent assets whose bytes match the request, rejects an +# existing name with different state/size/digest, and uploads the generated +# programa-release-candidate.json manifest last. That manifest is the seal and +# must also be written byte-for-byte to the requested local output path. It +# 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:`. +# Payload appcast and daemon-manifest 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. +# +# GH_BIN=/path/to/gh GITHUB_REPOSITORY=owner/repo \ +# scripts/publish_rolling_release.sh \ +# --candidate-prefix rolling-candidate- --rolling-tag rolling \ +# --reconciler-target-sha +# +# GitHub Actions serializes reconciler jobs externally. The helper creates no +# 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 +# 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 +# it backward, while an equal-build candidate repairs drift. Every mutation is +# retry-safe. A selected draft is published first, at its existing build tag, as +# a non-latest prerelease archive before rolling changes. Repository release +# immutability must remain disabled because rolling is intentionally reused; +# archive integrity comes from its sealed bytes and attestations instead. +# Rolling reconciles only appcast.xml and programa-macos.dmg; build-specific +# payloads remain in their permanent archive and are never copied into rolling. +# Metadata and latest status change before the rolling ref moves. Stale drafts +# may be deleted after final verification, but the selected archive remains. +# Rolling must already exist as the legacy mutable release; missing or immutable +# state fails. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CANDIDATE_HELPER="${ROOT_DIR}/scripts/publish_release_candidate.sh" +ROLLING_HELPER="${ROOT_DIR}/scripts/publish_rolling_release.sh" +RESTORE_HELPER="${ROOT_DIR}/scripts/restore_release_candidate.sh" +MILESTONE_MODULE="${ROOT_DIR}/scripts/milestone_payload.js" + +[[ -x "${CANDIDATE_HELPER}" ]] || { + echo "FAIL: missing executable candidate publisher at scripts/publish_release_candidate.sh" >&2 + exit 1 +} +[[ -x "${ROLLING_HELPER}" ]] || { + echo "FAIL: missing executable state-aware reconciler at scripts/publish_rolling_release.sh" >&2 + exit 1 +} +[[ -x "${RESTORE_HELPER}" && -r "${MILESTONE_MODULE}" ]] || { + echo "FAIL: missing milestone candidate restore helper or payload module" >&2 + exit 1 +} + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/programa-candidate-publication.XXXXXX")" +cleanup_harness() { + local status=$? + # A helper call that fails under `set -e` would otherwise exit this harness + # silently; echo the captured helper output so CI logs show the cause. + if [[ "${status}" -ne 0 && -s "${TMP_DIR}/run.out" ]]; then + echo "--- last helper output (exit ${status}) ---" >&2 + cat "${TMP_DIR}/run.out" >&2 + fi + rm -rf "${TMP_DIR}" +} +trap cleanup_harness EXIT +STATE_DIR="${TMP_DIR}/state" +FIXTURE_DIR="${TMP_DIR}/fixtures" +FAKE_GH="${TMP_DIR}/gh" +RUN_OUTPUT="${TMP_DIR}/run.out" +REPOSITORY="darkroomengineering/programa" +SEAL_NAME="programa-release-candidate.json" +ED25519_SIGNATURE="AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ==" + +fail() { echo "FAIL: $*" >&2; exit 1; } +sha256_file() { shasum -a 256 "$1" | awk '{print $1}'; } +digest_file() { printf 'sha256:%s' "$(sha256_file "$1")"; } +file_size() { stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1"; } +target_sha_for() { printf '%040x' "$1"; } +release_dir() { printf '%s/releases/%s' "${STATE_DIR}" "$1"; } +asset_dir() { printf '%s/assets/%s' "$(release_dir "$1")" "$2"; } + +write_release() { + local tag="$1" target_sha="$2" draft="$3" latest="$4" title="$5" body="$6" prerelease="${7:-false}" dir + dir="$(release_dir "${tag}")" + mkdir -p "${dir}/assets" + printf '%s\n' "${tag}" > "${dir}/tag" + printf '%s\n' "${target_sha}" > "${dir}/target_sha" + printf '%s\n' "${draft}" > "${dir}/draft" + printf 'false\n' > "${dir}/immutable" + printf '%s\n' "${latest}" > "${dir}/latest" + printf '%s\n' "${prerelease}" > "${dir}/prerelease" + printf '%s\n' "${title}" > "${dir}/title" + printf '%s\n' "${body}" > "${dir}/body" +} + +write_asset() { + local tag="$1" name="$2" source="$3" dir + dir="$(asset_dir "${tag}" "${name}")" + mkdir -p "${dir}" + if [[ -f "${STATE_DIR}/next_asset_id" ]]; then + local asset_id + asset_id="$(cat "${STATE_DIR}/next_asset_id")" + printf '%s\n' "$((asset_id + 1))" > "${STATE_DIR}/next_asset_id" + printf '%s\n' "${asset_id}" > "${dir}/id" + fi + cp "${source}" "${dir}/bytes" + printf 'uploaded\n' > "${dir}/state" + file_size "${source}" > "${dir}/size" + printf '%s\n' "$(digest_file "${source}")" > "${dir}/digest" +} + +assert_file_equals() { + local file="$1" expected="$2" actual + [[ -f "${file}" ]] || fail "missing ${file#${STATE_DIR}/}" + actual="$(cat "${file}")" + [[ "${actual}" == "${expected}" ]] || fail "${file#${STATE_DIR}/} was '${actual}', expected '${expected}'" +} +assert_release_exists() { [[ -d "$(release_dir "$1")" ]] || fail "missing release $1"; } +assert_release_absent() { [[ ! -d "$(release_dir "$1")" ]] || fail "release $1 still exists"; } +assert_asset_count() { + local tag="$1" expected="$2" actual + actual="$(find "$(release_dir "${tag}")/assets" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + [[ "${actual}" == "${expected}" ]] || fail "release ${tag} has ${actual} assets, expected ${expected}" +} +assert_asset_equals() { + cmp -s "$(asset_dir "$1" "$2")/bytes" "$3" || fail "$1/$2 does not contain the expected bytes" +} +assert_no_asset_write() { + if grep -Eq "^mutation (delete-asset|upload-asset) rolling $1$" "${STATE_DIR}/operations.log"; then + fail "converged retry wrote rolling asset $1" + fi +} + +reset_state() { + rm -rf "${STATE_DIR}" + mkdir -p "${STATE_DIR}/releases" + : > "${STATE_DIR}/operations.log" + printf '1000\n' > "${STATE_DIR}/next_asset_id" + printf '500\n' > "${STATE_DIR}/next_release_id" +} + +make_fixture() { + local build="$1" version="$2" destination="${3:-rolling}" dir="${FIXTURE_DIR}/$1" + 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" < "${dir}/appcast.xml" < + + ${version} + ${build} + + + +EOF +} + +poison_appcast_url() { + local build="$1" file="${FIXTURE_DIR}/$1/appcast.xml" + cat > "${file}" < + harmless https://github.com/${REPOSITORY}/releases/download/rolling/ text + ${build} + +EOF +} + +write_invalid_appcast_item() { + local build="$1" shape="$2" file="${FIXTURE_DIR}/$1/appcast.xml" version enclosure item + version="${build}" + enclosure="" + case "${shape}" in + missing-version) item="${enclosure}" ;; + duplicate-version) item="${version}${version}${enclosure}" ;; + duplicate-enclosure) item="${version}${enclosure}${enclosure}" ;; + mismatched-enclosure) item="${version}" ;; + *) fail "unknown invalid appcast shape ${shape}" ;; + esac + printf '%s\n' \ + "${item}" > "${file}" +} + +poison_daemon_manifest_url() { + local build="$1" file="${FIXTURE_DIR}/$1/programad-remote-manifest-$1.json" + cat > "${file}" < "${RUN_OUTPUT}" 2>&1 +} + +prepare_candidate() { + local build="$1" version="$2" prefix="${3:-rolling-candidate-}" destination="${4:-rolling}" attempt="${5:-}" role seal_output candidate_tag + candidate_tag="${prefix}${build}${attempt:+-${attempt}}" + seal_output="$(seal_output_for "${candidate_tag}")" + rm -f "${seal_output}" + local args=(--prepare-only --candidate-prefix "${prefix}" --destination-tag "${destination}" --candidate-tag "${candidate_tag}" --target-sha "$(target_sha_for "${build}")" --build "${build}" --version "${version}" --seal-output "${seal_output}") + while IFS= read -r role; do args+=(--asset-role "${role}"); done < <(fixture_roles "${build}") + GH_BIN="${FAKE_GH}" GITHUB_REPOSITORY="${REPOSITORY}" FAKE_GH_STATE_DIR="${STATE_DIR}" \ + "${CANDIDATE_HELPER}" "${args[@]}" > "${RUN_OUTPUT}" 2>&1 +} + +stage_prepared_candidate() { + local build="$1" version="$2" prefix="${3:-rolling-candidate-}" destination="${4:-rolling}" attempt="${5:-}" role seal_output candidate_tag + candidate_tag="${prefix}${build}${attempt:+-${attempt}}" + seal_output="$(seal_output_for "${candidate_tag}")" + [[ -f "${seal_output}" ]] || fail "prepared seal is missing for ${candidate_tag}" + local args=(--candidate-prefix "${prefix}" --destination-tag "${destination}" --candidate-tag "${candidate_tag}" --target-sha "$(target_sha_for "${build}")" --build "${build}" --version "${version}" --seal-output "${seal_output}") + while IFS= read -r role; do args+=(--asset-role "${role}"); done < <(fixture_roles "${build}") + GH_BIN="${FAKE_GH}" GITHUB_REPOSITORY="${REPOSITORY}" FAKE_GH_STATE_DIR="${STATE_DIR}" \ + "${CANDIDATE_HELPER}" "${args[@]}" > "${RUN_OUTPUT}" 2>&1 +} + +invoke_restore() { + local output_dir="$1" destination="${2:-v1.2.3}" + GH_BIN="${FAKE_GH}" GITHUB_REPOSITORY="${REPOSITORY}" FAKE_GH_STATE_DIR="${STATE_DIR}" \ + FAKE_GH_EXPECT_SOURCE_REF="refs/tags/${destination}" FAKE_GH_FAIL_ATTESTATION="${FAKE_GH_FAIL_ATTESTATION:-}" \ + "${RESTORE_HELPER}" --candidate-prefix milestone-candidate- --destination-tag "${destination}" \ + --target-sha "$(target_sha_for 201)" --build 201 --version 1.2.3 --output-dir "${output_dir}" > "${RUN_OUTPUT}" 2>&1 +} + +invoke_rolling() { + local stop_after="${1:-}" fail_point="${2:-}" reconciler_target="${3:-$(cat "${STATE_DIR}/main_sha")}" + GH_BIN="${FAKE_GH}" GITHUB_REPOSITORY="${REPOSITORY}" FAKE_GH_STATE_DIR="${STATE_DIR}" \ + FAKE_GH_STOP_AFTER_MUTATION="${stop_after}" FAKE_GH_FAIL_POINT="${fail_point}" \ + FAKE_GH_EXPOSE_MILESTONE_APPCAST="${FAKE_GH_EXPOSE_MILESTONE_APPCAST:-}" \ + FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA="${FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA:-}" \ + FAKE_GH_DUPLICATE_APPCAST_TAG="${FAKE_GH_DUPLICATE_APPCAST_TAG:-}" \ + FAKE_GH_ADVANCE_MAIN_AFTER_ARCHIVE="${FAKE_GH_ADVANCE_MAIN_AFTER_ARCHIVE:-}" \ + FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA="${FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA:-}" \ + FAKE_GH_ADVANCE_MAIN_DURING_NOTES="${FAKE_GH_ADVANCE_MAIN_DURING_NOTES:-}" \ + FAKE_GH_SWAP_SEAL_ON_PUBLISH="${FAKE_GH_SWAP_SEAL_ON_PUBLISH:-}" \ + FAKE_GH_FAIL_ATTESTATION="${FAKE_GH_FAIL_ATTESTATION:-}" FAKE_GH_CI_RESULT="${FAKE_GH_CI_RESULT:-success}" \ + "${ROLLING_HELPER}" --candidate-prefix rolling-candidate- --rolling-tag rolling \ + --reconciler-target-sha "${reconciler_target}" > "${RUN_OUTPUT}" 2>&1 +} + +cat > "${FAKE_GH}" <<'FAKE_GH_EOF' +#!/usr/bin/env bash +set -euo pipefail +STATE_DIR="${FAKE_GH_STATE_DIR:?}" +RELEASES="${STATE_DIR}/releases" +LOG="${STATE_DIR}/operations.log" +release_dir() { printf '%s/%s' "${RELEASES}" "$1"; } +asset_dir() { printf '%s/assets/%s' "$(release_dir "$1")" "$2"; } +file_size() { stat -c '%s' "$1" 2>/dev/null || stat -f '%z' "$1"; } +digest_file() { printf 'sha256:%s' "$(shasum -a 256 "$1" | awk '{print $1}')"; } +log() { printf '%s\n' "$*" >> "${LOG}"; } +next_id() { local file="$1" value; value="$(cat "${file}")"; printf '%s\n' "$((value + 1))" > "${file}"; printf '%s' "${value}"; } +maybe_fail() { [[ "${FAKE_GH_FAIL_POINT:-}" != "$1" ]] || { echo "injected failure: $1" >&2; exit 42; }; } +mutation() { + local operation="$*" count + count="$(( $(cat "${STATE_DIR}/mutation_count" 2>/dev/null || echo 0) + 1 ))" + printf '%s\n' "${count}" > "${STATE_DIR}/mutation_count" + log "mutation ${operation}" + if [[ -n "${FAKE_GH_STOP_AFTER_MUTATION:-}" && "${count}" == "${FAKE_GH_STOP_AFTER_MUTATION}" ]]; then + echo "hard stop after public mutation ${count}: ${operation}" >&2; exit 97 + fi +} + +release_create() { + local tag="$1"; shift + local draft=false prerelease=false target="" title="" notes="" + while (($#)); do case "$1" in + --draft) draft=true; shift ;; --target) target="$2"; shift 2 ;; --title) title="$2"; shift 2 ;; + --prerelease) prerelease=true; shift ;; + --notes) notes="$2"; shift 2 ;; --notes-file) notes="$(cat "$2")"; shift 2 ;; --repo) shift 2 ;; *) shift ;; + esac; done + [[ ! -e "$(release_dir "${tag}")" ]] || { echo "release exists" >&2; exit 1; } + local dir="$(release_dir "${tag}")"; mkdir -p "${dir}/assets" + next_id "${STATE_DIR}/next_release_id" > "${dir}/id" + printf '%s\n' "${tag}" > "${dir}/tag"; printf '%s\n' "${target}" > "${dir}/target_sha" + printf '%s\n' "${draft}" > "${dir}/draft"; printf 'false\n' > "${dir}/latest" + printf '%s\n' "${prerelease}" > "${dir}/prerelease" + printf 'false\n' > "${dir}/immutable" + printf '%s\n' "${title}" > "${dir}/title"; printf '%s\n' "${notes}" > "${dir}/body" + mutation "create-release ${tag} draft=${draft}" +} + +release_list() { + local dir include_prerelease=false limit=999999 argument + while (($#)); do + argument="$1" + case "${argument}" in + --limit) limit="$2"; shift 2 ;; + *isPrerelease*) include_prerelease=true; shift ;; + *) shift ;; + esac + done + log "list-releases" + for dir in "${RELEASES}"/*; do [[ -d "${dir}" ]] || continue + if [[ "${include_prerelease}" == true ]]; then + printf '%s\t%s\t%s\t%s\n' "$(cat "${dir}/tag")" "$(cat "${dir}/draft")" \ + "$(cat "${dir}/latest")" "$(cat "${dir}/prerelease")" + else + printf '%s\t%s\t%s\n' "$(cat "${dir}/tag")" "$(cat "${dir}/draft")" "$(cat "${dir}/latest")" + fi + done | LC_ALL=C sort -k1,1 | awk -v limit="${limit}" 'NR <= limit' +} + +release_view() { + local tag="$1"; shift; local query="" + while (($#)); do case "$1" in --jq) query="$2"; shift 2 ;; --json|--repo) shift 2 ;; *) shift ;; esac; done + local dir="$(release_dir "${tag}")"; [[ -d "${dir}" ]] || { echo "release not found" >&2; exit 1; } + log "view-release ${tag}" + case "${query}" in + .databaseId|.id) cat "${dir}/id" ;; .tagName) cat "${dir}/tag" ;; .isDraft) cat "${dir}/draft" ;; .isImmutable) cat "${dir}/immutable" ;; + .isPrerelease) cat "${dir}/prerelease" ;; + .isLatest) echo "unknown JSON field: isLatest" >&2; exit 2 ;; .targetCommitish) cat "${dir}/target_sha" ;; + .name) cat "${dir}/title" ;; .body) cat "${dir}/body" ;; + *assets*'@tsv'*) + if [[ "${tag}" == rolling && -f "${dir}/assets/appcast.xml/bytes" ]]; then + build="$(sed -n 's|.*\([0-9][0-9]*\).*|\1|p' "${dir}/assets/appcast.xml/bytes" | head -1)" + log "verify-rolling rolling ${build}" + maybe_fail "verify:rolling" + fi + local asset; for asset in "${dir}/assets"/*; do [[ -d "${asset}" ]] || continue + printf '%s\t%s\t%s\t%s\t%s\n' "$(cat "${asset}/id")" "$(basename "${asset}")" \ + "$(cat "${asset}/state")" "$(cat "${asset}/size")" "$(cat "${asset}/digest")" + if [[ "${tag}" == "${FAKE_GH_DUPLICATE_APPCAST_TAG:-}" && "$(basename "${asset}")" == appcast.xml ]]; then + printf '%s\t%s\t%s\t%s\t%s\n' "$(cat "${asset}/id")" "$(basename "${asset}")" \ + "$(cat "${asset}/state")" "$(cat "${asset}/size")" "$(cat "${asset}/digest")" + fi + done | LC_ALL=C sort -k2,2 ;; + *) printf '%s\t%s\t%s\t%s\t%s\n' "$(cat "${dir}/id")" "${tag}" "$(cat "${dir}/draft")" \ + "$(cat "${dir}/latest")" "$(cat "${dir}/target_sha")" ;; + esac +} + +release_upload() { + local tag="$1"; shift; local files=() clobber=false + while (($#)); do case "$1" in --clobber) clobber=true; shift ;; --repo) shift 2 ;; *) files+=("$1"); shift ;; esac; done + local file name dir corrupt="${FAKE_GH_CORRUPT_UPLOAD:-}" + [[ "$(cat "$(release_dir "${tag}")/immutable")" == false ]] || { echo "release is immutable" >&2; exit 1; } + for file in "${files[@]}"; do + name="$(basename "${file}")"; dir="$(asset_dir "${tag}" "${name}")" + if [[ -d "${dir}" && "${clobber}" != true ]]; then echo "asset already exists: ${name}" >&2; exit 1; fi + if [[ -d "${dir}" ]]; then rm -rf "${dir}"; mutation "delete-asset ${tag} ${name}"; fi + maybe_fail "upload:${tag}:${name}" + mkdir -p "${dir}"; next_id "${STATE_DIR}/next_asset_id" > "${dir}/id"; cp "${file}" "${dir}/bytes" + printf 'uploaded\n' > "${dir}/state"; file_size "${file}" > "${dir}/size" + printf '%s\n' "$(digest_file "${file}")" > "${dir}/digest" + 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 + 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" + printf '%s\n' "$(digest_file "${FAKE_GH_EXPOSE_MILESTONE_APPCAST}")" > "${milestone_asset}/digest" + log "milestone-appcast-advanced" + fi + if [[ "${tag}" == rolling && "${name}" == programa-macos.dmg && -n "${FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA:-}" ]]; then + milestone_asset="$(asset_dir v0.63.0 appcast.xml)" + cp "${FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA}" "${milestone_asset}/bytes" + file_size "${FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA}" > "${milestone_asset}/size" + printf '%s\n' "$(digest_file "${FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA}")" > "${milestone_asset}/digest" + log "milestone-appcast-advanced-before-metadata" + fi + done +} + +release_download() { + local tag="$1"; shift; local pattern="" destination="." + while (($#)); do case "$1" in -p|--pattern) pattern="$2"; shift 2 ;; -D|--dir) destination="$2"; shift 2 ;; + --repo) shift 2 ;; *) shift ;; esac; done + maybe_fail "download:${tag}:${pattern}"; mkdir -p "${destination}" + cp "$(asset_dir "${tag}" "${pattern}")/bytes" "${destination}/${pattern}" + log "authenticated-download ${tag} ${pattern}" + if [[ "${tag}" == rolling && "${pattern}" == programa-macos.dmg && -n "${FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA:-}" && \ + ! -f "${STATE_DIR}/main_advanced_before_metadata" ]] && \ + grep -Fq 'mutation upload-asset rolling programa-macos.dmg' "${LOG}"; then + printf '%s\n' "${FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA}" > "${STATE_DIR}/main_sha" + : > "${STATE_DIR}/main_advanced_before_metadata" + log "main-advanced-before-metadata ${FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA}" + fi +} + +release_delete_asset() { + local tag="$1" name="$2" + [[ "$(cat "$(release_dir "${tag}")/immutable")" == false ]] || { echo "release is immutable" >&2; exit 1; } + rm -rf "$(asset_dir "${tag}" "${name}")"; mutation "delete-asset ${tag} ${name}" +} +release_edit() { + local tag="$1"; shift; local dir="$(release_dir "${tag}")" title="" body="" draft="" latest="" prerelease="" seal_dir="" + local was_draft; was_draft="$(cat "${dir}/draft")" + while (($#)); do case "$1" in + --title) title="$2"; shift 2 ;; --notes) body="$2"; shift 2 ;; --notes-file) body="$(cat "$2")"; shift 2 ;; + --draft) draft=false; shift ;; --draft=*) draft="${1#--draft=}"; shift ;; + --prerelease) prerelease=true; shift ;; --prerelease=*) prerelease="${1#--prerelease=}"; shift ;; + --latest) latest=true; shift ;; --latest=*) latest="${1#--latest=}"; shift ;; --repo) shift 2 ;; *) shift ;; + esac; done + [[ -z "${title}" ]] || printf '%s\n' "${title}" > "${dir}/title" + [[ -z "${body}" ]] || printf '%s\n' "${body}" > "${dir}/body" + [[ -z "${draft}" ]] || printf '%s\n' "${draft}" > "${dir}/draft" + [[ -z "${prerelease}" ]] || printf '%s\n' "${prerelease}" > "${dir}/prerelease" + if [[ -n "${latest}" ]]; then + if [[ "${latest}" == true ]]; then + local other; for other in "${RELEASES}"/*; do [[ -d "${other}" ]] && printf 'false\n' > "${other}/latest"; done + fi + printf '%s\n' "${latest}" > "${dir}/latest" + fi + mutation "edit-release ${tag} draft=$(cat "${dir}/draft") latest=$(cat "${dir}/latest") prerelease=$(cat "${dir}/prerelease")" + if [[ "${tag}" == rolling-candidate-* && "${was_draft}" == true && "$(cat "${dir}/draft")" == false ]]; then + if [[ -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" + printf '%s\n' "$(digest_file "${FAKE_GH_EXPOSE_MILESTONE_APPCAST}")" > "${milestone_asset}/digest" + log "milestone-appcast-advanced" + fi + if [[ -n "${FAKE_GH_ADVANCE_MAIN_AFTER_ARCHIVE:-}" ]]; then + printf '%s\n' "${FAKE_GH_ADVANCE_MAIN_AFTER_ARCHIVE}" > "${STATE_DIR}/main_sha" + log "main-advanced-after-archive ${FAKE_GH_ADVANCE_MAIN_AFTER_ARCHIVE}" + fi + if [[ "${FAKE_GH_SWAP_SEAL_ON_PUBLISH:-}" == "${tag}" ]]; then + seal_dir="$(asset_dir "${tag}" programa-release-candidate.json)" + printf '{"swapped":true}\n' > "${seal_dir}/bytes" + file_size "${seal_dir}/bytes" > "${seal_dir}/size" + printf '%s\n' "$(digest_file "${seal_dir}/bytes")" > "${seal_dir}/digest" + log "seal-swapped-on-publish ${tag}" + fi + fi +} +release_delete() { + local tag="$1" + [[ "$(cat "$(release_dir "${tag}")/immutable")" == false ]] || { echo "release is immutable" >&2; exit 1; } + rm -rf "$(release_dir "${tag}")"; mutation "delete-release ${tag}" +} + +attestation_verify() { + local file="$1"; shift + local repo="" signer_workflow="" source_ref="" source_digest="" deny_self_hosted=false name expected_digest + while (($#)); do case "$1" in + --repo) repo="$2"; shift 2 ;; --signer-workflow) signer_workflow="$2"; shift 2 ;; + --source-ref) source_ref="$2"; shift 2 ;; --deny-self-hosted-runners) deny_self_hosted=true; shift ;; + --source-digest) source_digest="$2"; shift 2 ;; + *) echo "unsupported fake gh attestation argument: $1" >&2; exit 2 ;; + esac; done + [[ "${repo}" == darkroomengineering/programa ]] || { echo "attestation repo constraint missing" >&2; exit 2; } + [[ "${signer_workflow}" == darkroomengineering/programa/.github/workflows/release.yml ]] || { echo "attestation signer workflow constraint missing" >&2; exit 2; } + [[ "${source_ref}" == "${FAKE_GH_EXPECT_SOURCE_REF:-refs/heads/main}" ]] || { echo "attestation source ref constraint missing" >&2; exit 2; } + expected_digest="$(cat "${STATE_DIR}/main_sha")" + [[ "${source_digest}" == "${expected_digest}" ]] || { echo "attestation source digest constraint missing or stale" >&2; exit 2; } + [[ "${deny_self_hosted}" == true ]] || { echo "self-hosted runners were not denied" >&2; exit 2; } + name="$(basename "${file}")"; log "attestation-verify ${name} source=${source_digest}" + if [[ "${name}" == programa-release-candidate.json ]] && grep -Fq '"swapped":true' "${file}"; then + echo "published seal does not match its attestation" >&2 + exit 42 + fi + if [[ "${FAKE_GH_FAIL_ATTESTATION:-}" == "${name}" ]]; then echo "injected attestation failure: ${name}" >&2; exit 42; fi +} + +api_command() { + local endpoint="" method=GET ref="" sha="" target="" previous="" tag_name="" head_sha="" query="" notes_start="" + while (($#)); do case "$1" in + --paginate) shift ;; + -X|--method) method="$2"; shift 2 ;; -f|-F) + case "$2" in ref=*) ref="${2#ref=}" ;; sha=*) sha="${2#sha=}" ;; + target_commitish=*) target="${2#target_commitish=}" ;; previous_tag_name=*) previous="${2#previous_tag_name=}" ;; + tag_name=*) tag_name="${2#tag_name=}" ;; head_sha=*) head_sha="${2#head_sha=}" ;; esac; shift 2 ;; + --jq) query="$2"; shift 2 ;; --repo) shift 2 ;; + *) [[ -z "${endpoint}" ]] && endpoint="$1"; shift ;; + esac; done + if [[ -z "${head_sha}" && "${endpoint}" == *head_sha=* ]]; then + head_sha="${endpoint#*head_sha=}"; head_sha="${head_sha%%&*}" + fi + case "${endpoint}:${method}" in + */immutable-releases:*) + log "forbidden-immutable-releases-endpoint" + echo "the workflow token has no Administration permission" >&2 + exit 86 ;; + */releases\?per_page=100:GET) + local release + log "api-list-releases-paginated" + # One paginated REST snapshot supplies every release-state field needed + # for discovery and verification: tag, draft, prerelease, immutable, SHA. + for release in "${RELEASES}"/*; do + [[ -d "${release}" ]] || continue + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$(cat "${release}/tag")" \ + "$(cat "${release}/draft")" \ + "$(cat "${release}/prerelease")" \ + "$(cat "${release}/immutable")" \ + "$(cat "${release}/target_sha")" + done | LC_ALL=C sort -k1,1 ;; + */git/ref/heads/main:GET|*/git/refs/heads/main:GET) + local current_main; current_main="$(cat "${STATE_DIR}/main_sha")" + printf '%s\n' "${current_main}"; log "read-main-ref ${current_main}" ;; + */git/ref/tags/*:GET) + local tag="${endpoint##*/tags/}" observed + [[ ! -f "$(release_dir "${tag}")/missing_ref" ]] || { echo "ref not found" >&2; exit 1; } + observed="$(cat "$(release_dir "${tag}")/target_sha")" + printf '%s\n' "${observed}"; log "read-ref ${tag} ${observed}" ;; + */git/refs/tags/*:PATCH) + local tag="${endpoint##*/tags/}" + [[ ! -f "$(release_dir "${tag}")/missing_ref" ]] || { echo "ref not found" >&2; exit 1; } + printf '%s\n' "${sha}" > "$(release_dir "${tag}")/target_sha" + mutation "move-ref ${tag} ${sha}" ;; + */git/refs:POST) + [[ "${ref}" == refs/tags/* ]] || { echo "unsupported ref create: ${ref}" >&2; exit 2; } + local created_tag="${ref#refs/tags/}" + printf '%s\n' "${sha}" > "$(release_dir "${created_tag}")/target_sha" + rm -f "$(release_dir "${created_tag}")/missing_ref" + mutation "move-ref ${created_tag} ${sha}" ;; + */releases/generate-notes:POST) + [[ -n "${tag_name}" ]] || { echo "generate-notes requires tag_name" >&2; exit 2; } + log "generate-notes tag=${tag_name} target=${target} previous=${previous}" + notes_start="${previous}" + if [[ "${previous}" == rolling && -d "$(release_dir rolling)" ]]; then + notes_start="$(cat "$(release_dir rolling)/target_sha")" + fi + printf 'Generated notes from %s to %s\n' "${notes_start}" "${target}" + if [[ -n "${FAKE_GH_ADVANCE_MAIN_DURING_NOTES:-}" ]]; then + printf '%s\n' "${FAKE_GH_ADVANCE_MAIN_DURING_NOTES}" > "${STATE_DIR}/main_sha" + log "main-advanced-during-notes ${FAKE_GH_ADVANCE_MAIN_DURING_NOTES}" + fi ;; + */actions/workflows/*ci.yml/runs*:GET|*/actions/runs*:GET) + [[ "${head_sha}" =~ ^[a-f0-9]{40}$ ]] || { echo "workflow-runs query requires sealed head_sha" >&2; exit 2; } + log "ci-runs head=${head_sha}" + if [[ -n "${query}" ]]; then + [[ "${FAKE_GH_CI_RESULT:-success}" == success ]] && printf '1\n' || printf '0\n' + elif [[ "${FAKE_GH_CI_RESULT:-success}" == success ]]; then + printf '{"workflow_runs":[{"status":"completed","conclusion":"success","event":"push","head_branch":"main","head_sha":"%s","path":".github/workflows/ci.yml"}]}\n' "${head_sha}" + else + printf '{"workflow_runs":[]}\n' + fi ;; + *) echo "unsupported fake gh api: ${method} ${endpoint}" >&2; exit 2 ;; + esac +} + +command="${1:-}"; shift || true +case "${command}" in + release) sub="${1:-}"; shift || true; case "${sub}" in + create) release_create "$@" ;; list) release_list "$@" ;; view) release_view "$@" ;; upload) release_upload "$@" ;; + download) release_download "$@" ;; delete-asset) release_delete_asset "$@" ;; edit) release_edit "$@" ;; + delete) release_delete "$@" ;; *) echo "unsupported fake gh release command: ${sub}" >&2; exit 2 ;; esac ;; + attestation) sub="${1:-}"; shift || true; [[ "${sub}" == verify ]] || { echo "unsupported fake gh attestation command" >&2; exit 2; }; attestation_verify "$@" ;; + api) api_command "$@" ;; *) echo "unsupported fake gh command: ${command}" >&2; exit 2 ;; +esac +FAKE_GH_EOF +chmod +x "${FAKE_GH}" + +for build in 100 101 102 103 104 105 200 201; do make_fixture "${build}" "0.64.73"; done + +seed_sealed_candidate() { + local build="$1" version="${2:-0.64.73}" refresh_fixture="${3:-true}" dir role path name size digest entries="" + local tag="rolling-candidate-${build}" + # Archived payload URLs are self-contained at the candidate's permanent tag; + # rolling contains only the mutable feed and stable DMG alias. + [[ "${refresh_fixture}" != true ]] || make_fixture "${build}" "${version}" "${tag}" + write_release "${tag}" "$(target_sha_for "${build}")" true false "Candidate ${build}" candidate + printf '%s\n' "$((build + 500))" > "$(release_dir "${tag}")/id" + while IFS='=' read -r role path; do + name="$(basename "${path}")"; write_asset "${tag}" "${name}" "${path}" + printf '%s\n' "$((build * 10 + ${#entries}))" > "$(asset_dir "${tag}" "${name}")/id" + size="$(file_size "${path}")"; digest="$(sha256_file "${path}")"; [[ -z "${entries}" ]] || entries+="," + entries+="{\"name\":\"${name}\",\"role\":\"${role}\",\"size\":${size},\"sha256\":\"${digest}\"}" + done < <(fixture_roles "${build}") + dir="${TMP_DIR}/seal-${build}.json" + printf '{"schemaVersion":1,"sealed":true,"targetSha":"%s","version":"%s","build":"%s","assets":[%s]}\n' \ + "$(target_sha_for "${build}")" "${version}" "${build}" "${entries}" > "${dir}" + write_asset "${tag}" "${SEAL_NAME}" "${dir}" + printf '%s\n' "$((build * 10 + 9))" > "$(asset_dir "${tag}" "${SEAL_NAME}")/id" + printf '%s\n' "$(target_sha_for "${build}")" > "${STATE_DIR}/main_sha" +} + +stage_archive_candidate() { + local build="$1" version="${2:-0.64.73}" + local tag="rolling-candidate-${build}" + make_fixture "${build}" "${version}" "${tag}" + invoke_candidate "${build}" "${version}" "" rolling-candidate- "${tag}" + printf '%s\n' "$(target_sha_for "${build}")" > "${STATE_DIR}/main_sha" +} + +seed_rolling() { + local build="$1" draft="${2:-false}" latest="${3:-true}" role path + write_release rolling "$(target_sha_for "${build}")" "${draft}" "${latest}" "Rolling 0.64.73" "notes-${build}" + printf '42\n' > "$(release_dir rolling)/id" + while IFS='=' read -r role path; do write_asset rolling "$(basename "${path}")" "${path}"; done < <(fixture_roles "${build}") +} + +seed_release_decoys() { + local count="$1" index tag + for ((index = 1; index <= count; index += 1)); do + printf -v tag 'archive-decoy-%04d' "${index}" + write_release "${tag}" "$(target_sha_for "$((1000 + index))")" true false "Decoy ${index}" decoy + done +} + +seed_milestone() { + local build="$1" + local source="${2:-${FIXTURE_DIR}/${build}/appcast.xml}" + write_release v0.63.0 "$(target_sha_for "${build}")" false false 'Milestone 0.63.0' milestone + printf '63\n' > "$(release_dir v0.63.0)/id" + write_asset v0.63.0 appcast.xml "${source}" +} + +seed_milestone_tag() { + local tag="$1" build="$2" + local source="${3:-${FIXTURE_DIR}/${build}/appcast.xml}" + write_release "${tag}" "$(target_sha_for "${build}")" false false "Milestone ${tag#v}" milestone + write_asset "${tag}" appcast.xml "${source}" +} + +assert_candidate_sealed() { + local build="$1" version="${2:-0.64.73}" seal local_seal + local tag="${3:-rolling-candidate-${build}}" + seal="$(asset_dir "${tag}" "${SEAL_NAME}")/bytes"; assert_release_exists "${tag}" + local_seal="$(seal_output_for "${tag}")" + assert_file_equals "$(release_dir "${tag}")/draft" true; [[ -f "${seal}" ]] || fail "candidate ${build} is not sealed" + [[ -f "${local_seal}" ]] || fail "candidate ${build} did not write its requested local seal output" + cmp -s "${local_seal}" "${seal}" || fail "candidate ${build} local seal bytes differ from the uploaded seal" + assert_file_equals "$(release_dir "${tag}")/target_sha" "$(target_sha_for "${build}")" + node -e ' + const fs = require("node:fs"); const crypto = require("node:crypto"); + const [seal, build, version, assetRoot] = process.argv.slice(1); + const value = JSON.parse(fs.readFileSync(seal, "utf8")); + 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); + 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) { + if (Object.keys(a).sort().join(",") !== "name,role,sha256,size") process.exit(1); + const bytes = fs.readFileSync(`${assetRoot}/${a.name}/bytes`); + if (expected.get(a.name) !== a.role || a.size !== bytes.length || a.sha256 !== crypto.createHash("sha256").update(bytes).digest("hex")) process.exit(1); + expected.delete(a.name); + } + if (expected.size !== 0) process.exit(1); + ' "${seal}" "${build}" "${version}" "$(release_dir "${tag}")/assets" || fail "candidate ${build} seal has invalid contents" +} + +assert_rolling_converged() { + local build="$1" + assert_release_exists rolling; assert_file_equals "$(release_dir rolling)/target_sha" "$(target_sha_for "${build}")" + assert_file_equals "$(release_dir rolling)/draft" false; assert_file_equals "$(release_dir rolling)/latest" true + assert_asset_equals rolling appcast.xml "${FIXTURE_DIR}/${build}/appcast.xml" + assert_asset_equals rolling programa-macos.dmg "${FIXTURE_DIR}/${build}/programa-macos.dmg" +} + +assert_published_archive() { + local build="$1" + local tag="rolling-candidate-${build}" + assert_release_exists "${tag}" + assert_file_equals "$(release_dir "${tag}")/draft" false + 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 +} + +# A prepared seal is a durable handoff between build and staging. Preparation +# is local-only, while staging authenticates those exact bytes before the first +# candidate mutation and makes the seal the final upload. +reset_state +make_fixture 104 0.64.73 rolling +prepare_candidate 104 0.64.73 +rolling_prepared_seal="$(seal_output_for rolling-candidate-104)" +[[ -s "${rolling_prepared_seal}" ]] || fail "rolling prepare did not write a seal" +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "rolling seal preparation mutated GitHub" +cp "${rolling_prepared_seal}" "${TMP_DIR}/rolling-prepared-seal.snapshot" +: > "${STATE_DIR}/operations.log" +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" +[[ "$(tail -1 "${TMP_DIR}/rolling-prepared-uploads")" == *" ${SEAL_NAME}" ]] || fail "rolling prepared seal was not uploaded last" + +reset_state +make_fixture 201 1.2.3 v1.2.3 +prepare_candidate 201 1.2.3 milestone-candidate- v1.2.3 009 +milestone_prepared_seal="$(seal_output_for milestone-candidate-201-009)" +[[ -s "${milestone_prepared_seal}" ]] || fail "milestone prepare did not write a seal" +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "milestone seal preparation mutated GitHub" +cp "${milestone_prepared_seal}" "${TMP_DIR}/milestone-prepared-seal.snapshot" +: > "${STATE_DIR}/operations.log" +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" +[[ "$(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 +# bytes and payload bytes that no longer match the seal prepared for them. +reset_state +make_fixture 105 0.64.73 rolling +prepare_candidate 105 0.64.73 +printf ' ' >> "$(seal_output_for rolling-candidate-105)" +: > "${STATE_DIR}/operations.log" +if stage_prepared_candidate 105 0.64.73; then fail "rolling staging accepted modified prepared seal bytes"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "modified rolling prepared seal mutated candidate state" +assert_release_absent rolling-candidate-105 + +reset_state +make_fixture 201 1.2.3 v1.2.3 +prepare_candidate 201 1.2.3 milestone-candidate- v1.2.3 010 +printf 'changed-after-prepare\n' >> "${FIXTURE_DIR}/201/programa-dSYMs-201.zip" +: > "${STATE_DIR}/operations.log" +if stage_prepared_candidate 201 1.2.3 milestone-candidate- v1.2.3 010; then fail "milestone staging accepted payload bytes that differ from its prepared seal"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "stale milestone prepared seal mutated candidate state" +assert_release_absent milestone-candidate-201-010 + +# Milestone candidates survive workflow attempts and restore without mutating +# GitHub. A partial first attempt is ignored; the sealed second attempt is +# authenticated against the destination tag and restored into a real directory. +reset_state +make_fixture 201 1.2.3 v1.2.3 +if invoke_candidate 201 1.2.3 'upload:milestone-candidate-201-001:programa-dSYMs-201.zip' milestone-candidate- v1.2.3 001; then + fail "partial milestone candidate interruption was not propagated" +fi +invoke_candidate 201 1.2.3 '' milestone-candidate- v1.2.3 002 +assert_candidate_sealed 201 1.2.3 milestone-candidate-201-002 +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" +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" + +# 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" +rm -rf "${RESTORED}"; mkdir -p "${RESTORED}"; : > "${STATE_DIR}/operations.log" +if invoke_restore "${RESTORED}"; then fail "restore accepted tampered candidate bytes"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "tampered restore mutated GitHub" +cp "${FIXTURE_DIR}/201/programa-macos-201.dmg" "$(asset_dir milestone-candidate-201-002 programa-macos-201.dmg)/bytes" + +rm -rf "${RESTORED}"; mkdir -p "${RESTORED}"; : > "${STATE_DIR}/operations.log" +if FAKE_GH_FAIL_ATTESTATION="${SEAL_NAME}" invoke_restore "${RESTORED}"; then fail "restore ignored failed seal attestation"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "failed-attestation restore mutated GitHub" + +rm -rf "${RESTORED}"; mkdir -p "${RESTORED}"; : > "${STATE_DIR}/operations.log" +if invoke_restore "${RESTORED}" v9.9.9; then fail "restore accepted payload references for another destination"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "wrong-destination restore mutated GitHub" + +# Malformed seals fail closed even when another valid seal exists. +cp -R "$(release_dir milestone-candidate-201-002)" "$(release_dir milestone-candidate-201-003)" +printf '%s\n' milestone-candidate-201-003 > "$(release_dir milestone-candidate-201-003)/tag" +printf '{malformed\n' > "$(asset_dir milestone-candidate-201-003 "${SEAL_NAME}")/bytes" +file_size "$(asset_dir milestone-candidate-201-003 "${SEAL_NAME}")/bytes" > "$(asset_dir milestone-candidate-201-003 "${SEAL_NAME}")/size" +printf '%s\n' "$(digest_file "$(asset_dir milestone-candidate-201-003 "${SEAL_NAME}")/bytes")" > "$(asset_dir milestone-candidate-201-003 "${SEAL_NAME}")/digest" +: > "${STATE_DIR}/operations.log" +if invoke_restore "${RESTORED}"; then fail "restore ignored malformed sealed candidate"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "malformed-seal restore mutated GitHub" +rm -rf "$(release_dir milestone-candidate-201-003)" + +# Duplicate sealed payload identities are rejected consistently. +cp -R "$(release_dir milestone-candidate-201-002)" "$(release_dir milestone-candidate-201-003)" +printf '%s\n' milestone-candidate-201-003 > "$(release_dir milestone-candidate-201-003)/tag" +: > "${STATE_DIR}/operations.log" +if invoke_restore "${RESTORED}"; then fail "restore accepted duplicate sealed payload identities"; fi +! grep -q '^mutation ' "${STATE_DIR}/operations.log" || fail "duplicate restore mutated GitHub" + +# Candidate seal is last; verification downloads are authenticated; payload URLs remain stable. +reset_state +invoke_candidate 100 0.64.73 +assert_candidate_sealed 100 0.64.73 +last_upload="$(grep 'mutation upload-asset rolling-candidate-100' "${STATE_DIR}/operations.log" | tail -1)" +[[ "${last_upload}" == *" ${SEAL_NAME}" ]] || fail "candidate seal was not uploaded last" +grep -Fq 'authenticated-download rolling-candidate-100' "${STATE_DIR}/operations.log" || fail "candidate was not downloaded for verification" +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 + 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 + 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 +make_fixture 105 0.64.73 + +# Sparkle 2.9.4 publishes the build as an item child. Candidate validation +# rejects missing or ambiguous item associations and URL/build disagreement. +for invalid_shape in missing-version duplicate-version duplicate-enclosure mismatched-enclosure; do + reset_state; make_fixture 105 0.64.73; write_invalid_appcast_item 105 "${invalid_shape}" + if invoke_candidate 105 0.64.73; then fail "candidate staging accepted ${invalid_shape} appcast item"; fi + [[ ! -d "$(asset_dir rolling-candidate-105 "${SEAL_NAME}")" ]] || fail "${invalid_shape} appcast candidate was sealed" +done +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 +: > "${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 + ! 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 + 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 + 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 + assert_rolling_converged 104; assert_release_exists rolling-candidate-105 + ! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || \ + fail "poisoned ${poisoned_payload} mutated rolling" +done + +# A sealed candidate whose stored metadata later becomes corrupt cannot mutate rolling. +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" ;; + esac + : > "${STATE_DIR}/operations.log" + if invoke_rolling; then fail "promotion accepted corrupt candidate ${field}"; fi + assert_rolling_converged 101; assert_release_exists rolling-candidate-102 + ! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || \ + fail "corrupt candidate ${field} mutated rolling" +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}" +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 + 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" +done + +# The candidate seal and every downloaded payload must pass the exact +# release-workflow attestation policy and the sealed SHA must have successful CI +# before the first rolling mutation. +reset_state +seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log"; invoke_rolling +assert_rolling_converged 103 +expected_attestations="$(while IFS='=' read -r role path; do basename "${path}"; done < <(fixture_roles 103) | LC_ALL=C sort)" +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" +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" +grep -Fxq "attestation-verify ${SEAL_NAME} source=${expected_source}" "${STATE_DIR}/operations.log" || \ + fail "candidate seal attestation was not bound to the selected target SHA" +last_attestation_line="$(grep -n '^attestation-verify ' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +archive_publication_line="$(grep -n 'mutation edit-release rolling-candidate-103 draft=false latest=false prerelease=true' "${STATE_DIR}/operations.log" | head -1 | cut -d: -f1)" +published_seal_download_line="$(grep -n "^authenticated-download rolling-candidate-103 ${SEAL_NAME}$" "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +published_seal_attestation_line="$(grep -n "^attestation-verify ${SEAL_NAME} source=${expected_source}$" "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +ci_line="$(grep -n "^ci-runs head=$(target_sha_for 103)$" "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +first_rolling_mutation="$(grep -n -E '^mutation (delete-asset|upload-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" | head -1 | cut -d: -f1)" +[[ -n "${last_attestation_line}" && -n "${archive_publication_line}" && -n "${published_seal_download_line}" && \ + -n "${published_seal_attestation_line}" && \ + -n "${ci_line}" && -n "${first_rolling_mutation}" ]] || fail "provenance ordering evidence is incomplete" +(( last_attestation_line < first_rolling_mutation && ci_line < first_rolling_mutation )) || fail "rolling mutated before provenance gates completed" +(( archive_publication_line < published_seal_download_line && \ + published_seal_download_line < published_seal_attestation_line && \ + published_seal_attestation_line < first_rolling_mutation )) || \ + fail "published archive seal was not re-attested before rolling mutation" + +# 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 +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" + +reset_state +seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" +if FAKE_GH_FAIL_ATTESTATION="${SEAL_NAME}" invoke_rolling; then fail "failed candidate seal 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 "seal attestation failure mutated rolling" + +# A matching SHA without completed successful main-push CI is not promotable. +reset_state +seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" +if FAKE_GH_CI_RESULT=none invoke_rolling; then fail "candidate without successful CI was promoted"; fi +grep -Fq "ci-runs head=$(target_sha_for 103)" "${STATE_DIR}/operations.log" || fail "sealed target SHA was not queried in Actions" +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 "missing CI mutated rolling" + +# The sealed target must still be the current main tip before any public state +# changes. A successful historical CI run for an older SHA is not sufficient. +reset_state +seed_sealed_candidate 103; seed_rolling 102 +printf '%s\n' "$(target_sha_for 104)" > "${STATE_DIR}/main_sha" +: > "${STATE_DIR}/operations.log" +if invoke_rolling; then fail "candidate whose target is no longer main was promoted"; fi +grep -Fq "read-main-ref $(target_sha_for 104)" "${STATE_DIR}/operations.log" || fail "reconciler did not verify the current main ref" +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 "stale-main candidate mutated rolling" + +# A reconciler run belongs to one checked-out trigger SHA. It validates every +# visible seal but selects the candidate bound to this run, not the global max. +reset_state +seed_sealed_candidate 103; seed_sealed_candidate 104; seed_rolling 102 +printf '%s\n' "$(target_sha_for 103)" > "${STATE_DIR}/main_sha" +: > "${STATE_DIR}/operations.log" +invoke_rolling '' '' "$(target_sha_for 103)" +assert_rolling_converged 103; assert_published_archive 103; assert_release_exists rolling-candidate-104 +grep -Fq 'authenticated-download rolling-candidate-103 programa-release-candidate.json' "${STATE_DIR}/operations.log" || \ + fail "matching candidate seal was not validated" +grep -Fq 'authenticated-download rolling-candidate-104 programa-release-candidate.json' "${STATE_DIR}/operations.log" || \ + fail "nonmatching candidate seal was not validated" + +# Highest sealed decimal build wins; stale lower drafts prune; higher drafts remain. +reset_state +seed_sealed_candidate 100; seed_sealed_candidate 101; seed_sealed_candidate 103 +write_release rolling-candidate-099 "$(target_sha_for 99)" true false 'stale lower draft' stale; printf '599\n' > "$(release_dir rolling-candidate-099)/id" +write_release rolling-candidate-104 "$(target_sha_for 104)" true false 'higher draft' pending; printf '604\n' > "$(release_dir rolling-candidate-104)/id" +seed_rolling 100; invoke_rolling; assert_rolling_converged 103 +assert_release_absent rolling-candidate-099; assert_release_absent rolling-candidate-100 +assert_release_absent rolling-candidate-101; assert_published_archive 103; assert_release_exists rolling-candidate-104 + +# Lower candidates cannot regress rolling's high-water build. +reset_state +seed_sealed_candidate 103; seed_rolling 200; : > "${STATE_DIR}/operations.log"; invoke_rolling; assert_rolling_converged 200 +assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' +assert_file_equals "$(release_dir rolling)/body" 'notes-200' +! grep -Eq 'mutation (upload-asset|delete-asset|move-ref) rolling ' "${STATE_DIR}/operations.log" || fail "older candidate mutated newer rolling" + +# An advertised public appcast is authoritative: failed authenticated download +# or malformed XML blocks promotion instead of being treated as absent. +reset_state +seed_sealed_candidate 103; seed_rolling 102; seed_milestone 101; : > "${STATE_DIR}/operations.log" +if invoke_rolling '' 'download:v0.63.0:appcast.xml'; then fail "unavailable milestone appcast was treated as absent"; 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 "download failure mutated rolling" + +malformed_appcast="${TMP_DIR}/malformed-public-appcast.xml"; printf '\n' > "${malformed_appcast}" +reset_state +seed_sealed_candidate 103; seed_rolling 102; seed_milestone 101 "${malformed_appcast}"; : > "${STATE_DIR}/operations.log" +if invoke_rolling; then fail "malformed milestone appcast was treated as absent"; 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 "malformed public appcast mutated rolling" + +# Build order, not semantic milestone tag order, defines the public high-water. +# Every advertised milestone appcast must be authenticated and parsed. +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag v9.0.0 101 +seed_milestone_tag v1.0.0 200 +: > "${STATE_DIR}/operations.log"; invoke_rolling +assert_rolling_converged 102; assert_release_absent rolling-candidate-103 +grep -Fq 'authenticated-download v1.0.0 appcast.xml' "${STATE_DIR}/operations.log" || fail "lower-semver milestone appcast was not inspected" +! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || fail "candidate below an older-tag milestone build mutated rolling" + +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag v9.0.0 101 +seed_milestone_tag v1.0.0 100 "${malformed_appcast}" +: > "${STATE_DIR}/operations.log" +if invoke_rolling; then fail "malformed lower-semver milestone appcast 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 "malformed lower-semver milestone mutated rolling" + +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag v9.0.0 101 +seed_milestone_tag v1.0.0 100 +: > "${STATE_DIR}/operations.log" +if invoke_rolling '' 'download:v1.0.0:appcast.xml'; then fail "unreadable lower-semver milestone appcast 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 "unreadable lower-semver milestone mutated rolling" + +# Any published non-draft release that advertises appcast.xml contributes, +# regardless of its tag. Releases without appcast.xml are not public feed state. +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag customer-preview-2026 200 +: > "${STATE_DIR}/operations.log"; invoke_rolling +assert_rolling_converged 102; assert_release_absent rolling-candidate-103 +grep -Fq 'authenticated-download customer-preview-2026 appcast.xml' "${STATE_DIR}/operations.log" || \ + fail "arbitrary-tag advertised appcast was not inspected" +! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || \ + fail "candidate below arbitrary-tag high-water mutated rolling" + +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag customer-preview-2026 100 "${malformed_appcast}" +: > "${STATE_DIR}/operations.log" +if invoke_rolling; then fail "malformed arbitrary-tag appcast 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 "malformed arbitrary-tag appcast mutated rolling" + +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag customer-preview-2026 100 +: > "${STATE_DIR}/operations.log" +if invoke_rolling '' 'download:customer-preview-2026:appcast.xml'; then fail "unreadable arbitrary-tag appcast 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 "unreadable arbitrary-tag appcast mutated rolling" + +reset_state +seed_sealed_candidate 103; seed_rolling 102 +seed_milestone_tag customer-preview-2026 100 +: > "${STATE_DIR}/operations.log" +if FAKE_GH_DUPLICATE_APPCAST_TAG=customer-preview-2026 invoke_rolling; then fail "duplicate arbitrary-tag appcast 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 "duplicate arbitrary-tag appcast mutated rolling" + +reset_state +seed_sealed_candidate 103; seed_rolling 102 +write_release archive-without-feed "$(target_sha_for 99)" false false archive archive +: > "${STATE_DIR}/operations.log"; invoke_rolling +assert_rolling_converged 103; assert_published_archive 103 + +# A promotion seals its build-specific payload in-place before either mutable +# rolling alias changes. Repeated promotions replace only those two aliases, so +# the rolling release's asset count remains bounded while both archives retain +# the exact bytes that older clients may still download. +reset_state +seed_rolling 100 +seed_release_decoys 1005 +initial_rolling_asset_count="$(find "$(release_dir rolling)/assets" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" +stage_archive_candidate 101 +stage_archive_candidate 103 +prepublication_seal_103="${TMP_DIR}/prepublication-seal-103.json" +cp "$(asset_dir rolling-candidate-103 "${SEAL_NAME}")/bytes" "${prepublication_seal_103}" +: > "${STATE_DIR}/operations.log" +invoke_rolling +assert_published_archive 103 +assert_release_absent rolling-candidate-101 +assert_rolling_converged 103 +assert_asset_count rolling "${initial_rolling_asset_count}" +cmp -s "${prepublication_seal_103}" "$(asset_dir rolling-candidate-103 "${SEAL_NAME}")/bytes" || \ + fail "published archive seal differs from the selected pre-publication seal" +! grep -Fq 'forbidden-immutable-releases-endpoint' "${STATE_DIR}/operations.log" || \ + fail "publisher called the Administration-only immutable-releases endpoint" +! grep -Eq '^view-release archive-decoy-' "${STATE_DIR}/operations.log" || \ + fail "publisher performed per-release views for paginated historical decoys" +archive_line="$(grep -n 'mutation edit-release rolling-candidate-103 draft=false latest=false prerelease=true' "${STATE_DIR}/operations.log" | head -1 | cut -d: -f1)" +first_rolling_mutation="$(grep -n -E '^mutation (delete-asset|upload-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" | head -1 | cut -d: -f1)" +[[ -n "${archive_line}" && -n "${first_rolling_mutation}" ]] || fail "promotion omitted archive publication or rolling mutation" +(( archive_line < first_rolling_mutation )) || fail "rolling changed before the selected archive was published" +! grep -Eq '^mutation (upload-asset|delete-asset) rolling-candidate-103 ' "${STATE_DIR}/operations.log" || \ + fail "promotion rewrote selected archive assets" +if grep -E '^mutation (upload-asset|delete-asset) rolling ' "${STATE_DIR}/operations.log" | \ + grep -Ev ' rolling (appcast.xml|programa-macos.dmg)$'; then + fail "promotion copied build-specific assets into rolling" +fi + +stage_archive_candidate 104 +: > "${STATE_DIR}/operations.log" +invoke_rolling +assert_published_archive 103 +assert_published_archive 104 +assert_rolling_converged 104 +assert_asset_count rolling "${initial_rolling_asset_count}" +! grep -Fq 'forbidden-immutable-releases-endpoint' "${STATE_DIR}/operations.log" || \ + fail "repeated publisher called the Administration-only immutable-releases endpoint" +! grep -Eq '^view-release archive-decoy-' "${STATE_DIR}/operations.log" || \ + fail "repeated publisher performed per-release views for paginated historical decoys" +! grep -Eq '^mutation (upload-asset|delete-asset) rolling-candidate-104 ' "${STATE_DIR}/operations.log" || \ + fail "repeated promotion rewrote selected archive assets" +if grep -E '^mutation (upload-asset|delete-asset) rolling ' "${STATE_DIR}/operations.log" | \ + grep -Ev ' rolling (appcast.xml|programa-macos.dmg)$'; then + fail "repeated promotion grew rolling with build-specific assets" +fi + +# Publication is a trust-boundary race because archives intentionally remain +# mutable. If the public seal no longer equals the selected bytes, reconciliation +# must stop before either rolling alias changes. +reset_state +seed_rolling 100 +stage_archive_candidate 103 +prepublication_seal_103="${TMP_DIR}/prepublication-seal-race-103.json" +cp "$(asset_dir rolling-candidate-103 "${SEAL_NAME}")/bytes" "${prepublication_seal_103}" +: > "${STATE_DIR}/operations.log" +if FAKE_GH_SWAP_SEAL_ON_PUBLISH=rolling-candidate-103 invoke_rolling; then + fail "promotion accepted seal bytes swapped during archive publication" +fi +grep -Fq 'seal-swapped-on-publish rolling-candidate-103' "${STATE_DIR}/operations.log" || \ + fail "archive seal race hook was not reached" +assert_release_exists rolling-candidate-103 +assert_file_equals "$(release_dir rolling-candidate-103)/draft" false +assert_file_equals "$(release_dir rolling-candidate-103)/prerelease" true +if cmp -s "${prepublication_seal_103}" "$(asset_dir rolling-candidate-103 "${SEAL_NAME}")/bytes"; then + fail "archive seal race did not change the published bytes" +fi +! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || \ + fail "swapped published seal allowed rolling mutation" + +# Equal build repairs only the mutable feed and stable DMG alias. Existing +# build-specific rolling assets are legacy compatibility state and are neither +# added nor rewritten by new promotions. +reset_state +seed_sealed_candidate 103; seed_rolling 103 +rm -rf "$(asset_dir rolling appcast.xml)" +printf 'wrong stable\n' > "$(asset_dir rolling programa-macos.dmg)/bytes"; printf 'sha256:wrong\n' > "$(asset_dir rolling programa-macos.dmg)/digest" +printf 'wrong dsym\n' > "$(asset_dir rolling programa-dSYMs-103.zip)/bytes"; printf 'sha256:wrong\n' > "$(asset_dir rolling programa-dSYMs-103.zip)/digest" +: > "${STATE_DIR}/operations.log"; invoke_rolling; assert_rolling_converged 103 +assert_file_equals "$(asset_dir rolling programa-dSYMs-103.zip)/digest" 'sha256:wrong' +! grep -Eq '^mutation (upload-asset|delete-asset) rolling programa-dSYMs-103.zip$' "${STATE_DIR}/operations.log" || \ + fail "equal-build repair rewrote a legacy build-specific rolling asset" + +# Hard-stop after every observed public mutation; retry converges without rollback. +reset_state +seed_sealed_candidate 102; seed_rolling 101; invoke_rolling; mutation_total="$(cat "${STATE_DIR}/mutation_count")" +(( mutation_total > 0 )) || fail "promotion performed no public mutations" +ref_moved_retry_observed=false +for stop_after in $(seq 1 "${mutation_total}"); do + reset_state; seed_sealed_candidate 102; seed_rolling 101 + if invoke_rolling "${stop_after}"; then fail "hard stop ${stop_after} was not propagated"; fi + preserve_published_metadata=false + if [[ "$(cat "$(release_dir rolling)/target_sha")" == "$(target_sha_for 102)" ]]; then + preserve_published_metadata=true + ref_moved_retry_observed=true + published_title_before_retry="$(cat "$(release_dir rolling)/title")" + published_body_before_retry="$(cat "$(release_dir rolling)/body")" + [[ -n "${published_body_before_retry}" ]] || fail "ref moved without nonempty release notes" + [[ "${published_body_before_retry}" != "Generated notes from $(target_sha_for 102) to $(target_sha_for 102)" ]] || \ + fail "first publication already contained selected-to-selected notes" + fi + assert_release_exists rolling-candidate-102 + rm -f "${STATE_DIR}/mutation_count"; invoke_rolling + assert_rolling_converged 102; assert_published_archive 102 + if [[ "${preserve_published_metadata}" == true ]]; then + assert_file_equals "$(release_dir rolling)/title" "${published_title_before_retry}" + assert_file_equals "$(release_dir rolling)/body" "${published_body_before_retry}" + fi + verify_line="$(grep -n 'verify-rolling rolling 102' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" + [[ -n "${verify_line}" ]] || fail "retry omitted final rolling verification" + ! grep -Fq 'mutation delete-release rolling-candidate-102' "${STATE_DIR}/operations.log" || \ + fail "retry deleted the selected published archive" +done +[[ "${ref_moved_retry_observed}" == true ]] || fail "hard-stop matrix never exercised retry after the rolling ref moved" + +# A milestone that advances after archive publication is a second high-water gate. +# It must stop aliases, metadata, and the ref while retaining the candidate. +reset_state +seed_sealed_candidate 103; seed_rolling 102; seed_milestone 101; : > "${STATE_DIR}/operations.log" +FAKE_GH_EXPOSE_MILESTONE_APPCAST="${FIXTURE_DIR}/104/appcast.xml" invoke_rolling || race_status=$? +[[ "${race_status:-0}" -ne 0 ]] || fail "higher milestone race did not stop reconciliation" +grep -Fq 'milestone-appcast-advanced' "${STATE_DIR}/operations.log" || fail "milestone race hook was not reached" +assert_asset_equals rolling appcast.xml "${FIXTURE_DIR}/102/appcast.xml" +assert_asset_equals rolling programa-macos.dmg "${FIXTURE_DIR}/102/programa-macos.dmg" +assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' +assert_file_equals "$(release_dir rolling)/body" 'notes-102' +assert_file_equals "$(release_dir rolling)/target_sha" "$(target_sha_for 102)" +assert_release_exists rolling-candidate-103 +hook_line="$(grep -n 'milestone-appcast-advanced' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +if tail -n "+${hook_line}" "${STATE_DIR}/operations.log" | grep -Eq '^mutation (delete-asset|upload-asset) rolling (appcast.xml|programa-macos.dmg)$|^mutation (edit-release|move-ref) rolling'; then + fail "milestone race mutated aliases, metadata, or ref" +fi + +# Main can advance after the initial provenance gate. A recheck after +# archive publication must stop before appcast or stable-alias mutation. +reset_state +seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" +unset archive_main_race_status +FAKE_GH_ADVANCE_MAIN_AFTER_ARCHIVE="$(target_sha_for 104)" invoke_rolling || archive_main_race_status=$? +[[ "${archive_main_race_status:-0}" -ne 0 ]] || fail "post-archive main advancement did not stop reconciliation" +grep -Fq "main-advanced-after-archive $(target_sha_for 104)" "${STATE_DIR}/operations.log" || fail "post-archive main race hook was not reached" +assert_asset_equals rolling appcast.xml "${FIXTURE_DIR}/102/appcast.xml" +assert_asset_equals rolling programa-macos.dmg "${FIXTURE_DIR}/102/programa-macos.dmg" +assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' +assert_file_equals "$(release_dir rolling)/target_sha" "$(target_sha_for 102)" +assert_release_exists rolling-candidate-103 +archive_main_hook_line="$(grep -n 'main-advanced-after-archive' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +if tail -n "+${archive_main_hook_line}" "${STATE_DIR}/operations.log" | grep -Eq '^mutation (delete-asset|upload-asset) rolling (appcast.xml|programa-macos.dmg)$|^mutation (edit-release|move-ref) rolling'; then + fail "post-archive main race mutated aliases, metadata, or ref" +fi + +# A final high-water check immediately before publication prevents metadata, +# latest status, and the ref from advancing after aliases were reconciled. +reset_state +seed_sealed_candidate 103; seed_rolling 102; seed_milestone 101; : > "${STATE_DIR}/operations.log" +FAKE_GH_EXPOSE_MILESTONE_BEFORE_METADATA="${FIXTURE_DIR}/104/appcast.xml" invoke_rolling || final_race_status=$? +[[ "${final_race_status:-0}" -ne 0 ]] || fail "pre-publication milestone race did not stop reconciliation" +grep -Fq 'milestone-appcast-advanced-before-metadata' "${STATE_DIR}/operations.log" || fail "pre-publication race hook was not reached" +assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' +assert_file_equals "$(release_dir rolling)/body" 'notes-102' +assert_file_equals "$(release_dir rolling)/latest" true +assert_file_equals "$(release_dir rolling)/target_sha" "$(target_sha_for 102)" +assert_release_exists rolling-candidate-103 +final_hook_line="$(grep -n 'milestone-appcast-advanced-before-metadata' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +if tail -n "+${final_hook_line}" "${STATE_DIR}/operations.log" | grep -Eq '^mutation (edit-release|move-ref) rolling'; then + fail "pre-publication race changed metadata, latest status, or ref" +fi + +# Main can also advance after alias bytes verify. The final pre-publication +# recheck must preserve metadata, latest status, and the tag ref. +reset_state +seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" +unset final_main_race_status +FAKE_GH_ADVANCE_MAIN_BEFORE_METADATA="$(target_sha_for 104)" invoke_rolling || final_main_race_status=$? +[[ "${final_main_race_status:-0}" -ne 0 ]] || fail "pre-metadata main advancement did not stop reconciliation" +grep -Fq "main-advanced-before-metadata $(target_sha_for 104)" "${STATE_DIR}/operations.log" || fail "pre-metadata main race hook was not reached" +assert_asset_equals rolling appcast.xml "${FIXTURE_DIR}/103/appcast.xml" +assert_asset_equals rolling programa-macos.dmg "${FIXTURE_DIR}/103/programa-macos.dmg" +assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' +assert_file_equals "$(release_dir rolling)/body" 'notes-102' +assert_file_equals "$(release_dir rolling)/latest" true +assert_file_equals "$(release_dir rolling)/target_sha" "$(target_sha_for 102)" +assert_release_exists rolling-candidate-103 +final_main_hook_line="$(grep -n 'main-advanced-before-metadata' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +if tail -n "+${final_main_hook_line}" "${STATE_DIR}/operations.log" | grep -Eq '^mutation (edit-release|move-ref) rolling'; then + fail "pre-metadata main race changed metadata, latest status, or ref" +fi + +# Notes generation is an external call and can race with main advancing. The +# publisher must recheck main after notes return and before metadata/ref writes. +reset_state +seed_sealed_candidate 103; seed_rolling 102; : > "${STATE_DIR}/operations.log" +unset notes_main_race_status +FAKE_GH_ADVANCE_MAIN_DURING_NOTES="$(target_sha_for 104)" invoke_rolling || notes_main_race_status=$? +[[ "${notes_main_race_status:-0}" -ne 0 ]] || fail "main advancement during notes did not stop publication" +grep -Fq "main-advanced-during-notes $(target_sha_for 104)" "${STATE_DIR}/operations.log" || fail "notes main-race hook was not reached" +assert_file_equals "$(release_dir rolling)/title" 'Rolling 0.64.73' +assert_file_equals "$(release_dir rolling)/body" 'notes-102' +assert_file_equals "$(release_dir rolling)/target_sha" "$(target_sha_for 102)" +assert_release_exists rolling-candidate-103 +notes_main_hook_line="$(grep -n 'main-advanced-during-notes' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +if tail -n "+${notes_main_hook_line}" "${STATE_DIR}/operations.log" | grep -Eq '^mutation (edit-release|move-ref) rolling'; then + fail "notes main race changed metadata or ref" +fi + +# Generated notes use the rolling SHA observed at start; metadata/latest precede the final ref move. +reset_state +seed_sealed_candidate 103; seed_rolling 102; invoke_rolling +grep -Fq "generate-notes tag=rolling-next target=$(target_sha_for 103) previous=rolling" "${STATE_DIR}/operations.log" || fail "existing rolling notes used the wrong tags" +read_ref_line="$(grep -n "read-ref rolling $(target_sha_for 102)" "${STATE_DIR}/operations.log" | head -1 | cut -d: -f1)" +notes_line="$(grep -n 'generate-notes tag=rolling-next' "${STATE_DIR}/operations.log" | head -1 | cut -d: -f1)" +(( read_ref_line < notes_line )) || fail "notes were generated before reading the starting rolling ref" +metadata_line="$(grep -n 'mutation edit-release rolling' "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +tag_line="$(grep -n "mutation move-ref rolling $(target_sha_for 103)" "${STATE_DIR}/operations.log" | tail -1 | cut -d: -f1)" +(( metadata_line < tag_line )) || fail "rolling tag moved before metadata/latest" + +# Rolling is a pre-existing legacy mutable release. Missing or immutable state +# cannot be bootstrapped/repaired by the reconciler. +reset_state +seed_sealed_candidate 100; : > "${STATE_DIR}/operations.log" +if invoke_rolling; then fail "missing legacy rolling release was bootstrapped"; fi +assert_release_absent rolling; assert_release_exists rolling-candidate-100 +! grep -Eq '^mutation (create-release|upload-asset|edit-release|move-ref) rolling' "${STATE_DIR}/operations.log" || \ + fail "missing rolling release caused public mutation" + +reset_state +seed_sealed_candidate 101; seed_rolling 100 +printf 'true\n' > "$(release_dir rolling)/immutable" +: > "${STATE_DIR}/operations.log" +if invoke_rolling; then fail "immutable rolling release was accepted"; fi +assert_rolling_converged 100; assert_release_exists rolling-candidate-101 +! grep -Eq '^mutation (upload-asset|delete-asset|edit-release|move-ref) rolling ' "${STATE_DIR}/operations.log" || \ + fail "immutable rolling release was mutated" + +# Fully converged retry performs no asset writes. +reset_state; seed_sealed_candidate 100; seed_rolling 100; : > "${STATE_DIR}/operations.log"; rm -f "${STATE_DIR}/mutation_count"; invoke_rolling; assert_rolling_converged 100 +while IFS='=' read -r role path; do assert_no_asset_write "$(basename "${path}")"; done < <(fixture_roles 100) + +echo "PASS: sealed-archive-backed rolling publication is bounded, monotonic, and retry-convergent" diff --git a/tests_v2/test_browser_api_comprehensive.py b/tests_v2/test_browser_api_comprehensive.py index 3a4d631e..b623c3fd 100644 --- a/tests_v2/test_browser_api_comprehensive.py +++ b/tests_v2/test_browser_api_comprehensive.py @@ -60,6 +60,27 @@ def _expect_error_contains(label: str, fn, *needles: str) -> None: raise cmuxError(f"{label}: expected failure, but call succeeded") +def _expect_exact_error(label: str, fn, expected: str) -> None: + try: + fn() + except cmuxError as exc: + actual = str(exc) + if actual == expected: + return + raise cmuxError(f"{label}: expected exact error {expected!r}, got: {actual!r}") + raise cmuxError(f"{label}: expected exact error {expected!r}, but call succeeded") + + +def _assert_target_context(label: str, payload: dict, expected: dict) -> None: + for key, expected_value in expected.items(): + actual_value = payload.get(key) + _must(bool(actual_value), f"{label}: expected nonempty {key}: {payload}") + _must( + actual_value == expected_value, + f"{label}: expected {key}={expected_value!r}, got {actual_value!r}: {payload}", + ) + + def _value(res: dict, key: str = "value"): return (res or {}).get(key) @@ -150,9 +171,22 @@ def main() -> int: sid = str(opened.get("surface_id") or "") sref = str(opened.get("surface_ref") or "") _must(bool(sid), f"browser.open_split returned no surface_id: {opened}") + expected_target_context = { + key: opened.get(key) + for key in ( + "workspace_id", + "workspace_ref", + "surface_id", + "surface_ref", + "window_id", + "window_ref", + ) + } + _must( + all(bool(value) for value in expected_target_context.values()), + f"browser.open_split returned incomplete target context: {opened}", + ) target = sid - if sref: - _ = c._call("browser.url.get", {"surface_id": sref}) probe_url = _data_url("") c._call("browser.navigate", {"surface_id": target, "url": probe_url}) @@ -205,15 +239,29 @@ def main() -> int: timeout_s=3.0, label="browser.get.title page1", ) - url_payload = c._call("browser.url.get", {"surface_id": target}) or {} - _must("data:text/html" in str(url_payload.get("url") or ""), f"Expected data URL from browser.url.get: {url_payload}") + url_payload = c._call("browser.url.get", {"surface_id": sref}) or {} + _must(url_payload.get("surface_id") == target, f"Expected canonical surface_id from browser.url.get(ref): {url_payload}") + _must(url_payload.get("surface_ref") == sref, f"Expected surface_ref from browser.url.get(ref): {url_payload}") + _must( + url_payload.get("workspace_id") == opened.get("workspace_id"), + f"Expected workspace_id from browser.url.get(ref): {url_payload}", + ) + _must( + url_payload.get("workspace_ref") == opened.get("workspace_ref"), + f"Expected workspace_ref from browser.url.get(ref): {url_payload}", + ) + _must(page1_url in str(url_payload.get("url") or ""), f"Expected page1 data URL from browser.url.get(ref): {url_payload}") c._call("browser.fill", {"surface_id": target, "selector": "#name", "text": "cmux"}) - c._call("browser.click", {"surface_id": target, "selector": "#btn"}) + click_payload = c._call("browser.click", {"surface_id": target, "selector": "#btn"}) or {} + _must(click_payload.get("action") == "click", f"Expected click action metadata: {click_payload}") + _must(int(click_payload.get("attempts") or 0) == 1, f"Expected first-attempt click: {click_payload}") + _must(bool(click_payload.get("workspace_ref")), f"Expected workspace_ref from click: {click_payload}") + _must(bool(click_payload.get("surface_ref")), f"Expected surface_ref from click: {click_payload}") out_text = c._call("browser.get.text", {"surface_id": target, "selector": "#status"}) or {} _must(str(_value(out_text)) == "cmux", f"Expected status text to be cmux: {out_text}") - cleared = c._call("browser.fill", {"surface_id": target, "selector": "#name", "text": "", "snapshot_after": True}) or {} + cleared = c._call("browser.fill", {"surface_id": target, "selector": "#name", "value": "", "snapshot_after": True}) or {} _must(bool(cleared.get("post_action_snapshot")), f"Expected post_action_snapshot from fill(snapshot_after): {cleared}") cleared_value = c._call("browser.get.value", {"surface_id": target, "selector": "#name"}) or {} _must(str(_value(cleared_value)) == "", f"Expected fill with empty text to clear input: {cleared_value}") @@ -233,7 +281,9 @@ def main() -> int: c._call("browser.hover", {"surface_id": target, "selector": "#hover"}) c._call("browser.dblclick", {"surface_id": target, "selector": "#dbl"}) - c._call("browser.press", {"surface_id": target, "key": "A"}) + press_payload = c._call("browser.press", {"surface_id": target, "key": "A"}) or {} + _must(bool(press_payload.get("workspace_ref")), f"Expected workspace_ref from press: {press_payload}") + _must(bool(press_payload.get("surface_ref")), f"Expected surface_ref from press: {press_payload}") c._call("browser.keydown", {"surface_id": target, "key": "B"}) c._call("browser.keyup", {"surface_id": target, "key": "C"}) @@ -259,7 +309,7 @@ def main() -> int: is_unchecked = c._call("browser.is.checked", {"surface_id": target, "selector": "#chk"}) or {} _must(bool(_value(is_unchecked)) is False, f"Expected checked=false: {is_unchecked}") - c._call("browser.select", {"surface_id": target, "selector": "#sel", "value": "b"}) + c._call("browser.select", {"surface_id": target, "selector": "#sel", "text": "b"}) sel_val = c._call("browser.get.value", {"surface_id": target, "selector": "#sel"}) or {} _must(str(_value(sel_val)) == "b", f"Expected selected value b: {sel_val}") @@ -297,7 +347,9 @@ def main() -> int: _must(bool(_value(enabled_btn)) is True, f"Expected #btn enabled: {enabled_btn}") _must(bool(_value(enabled_disabled)) is False, f"Expected #disabled not enabled: {enabled_disabled}") - c._call("browser.scroll", {"surface_id": target, "selector": "#scroller", "dx": 0, "dy": 160}) + scroll_payload = c._call("browser.scroll", {"surface_id": target, "selector": "#scroller", "dx": 0, "dy": 160}) or {} + _must(bool(scroll_payload.get("workspace_ref")), f"Expected workspace_ref from scroll: {scroll_payload}") + _must(bool(scroll_payload.get("surface_ref")), f"Expected surface_ref from scroll: {scroll_payload}") scrolled = c._call( "browser.eval", {"surface_id": target, "script": "document.querySelector('#scroller').scrollTop"}, @@ -341,7 +393,8 @@ def main() -> int: label="browser.get.title page2", ) - c._call("browser.back", {"surface_id": target}) + back_payload = c._call("browser.back", {"surface_id": target}) or {} + _assert_target_context("browser.back", back_payload, expected_target_context) _wait_with_fallback( c, target, @@ -350,7 +403,8 @@ def main() -> int: timeout_s=5.0, label="browser.wait url_contains page1 (history)", ) - c._call("browser.forward", {"surface_id": target}) + forward_payload = c._call("browser.forward", {"surface_id": target}) or {} + _assert_target_context("browser.forward", forward_payload, expected_target_context) _wait_with_fallback( c, target, @@ -359,7 +413,32 @@ def main() -> int: timeout_s=5.0, label="browser.wait url_contains page2 (history)", ) - c._call("browser.reload", {"surface_id": target}) + reload_payload = c._call( + "browser.reload", + {"surface_id": target, "snapshot_after": True}, + ) or {} + _assert_target_context("browser.reload", reload_payload, expected_target_context) + snapshot_outcome_keys = [ + key + for key in ("post_action_snapshot", "post_action_snapshot_error") + if key in reload_payload + ] + _must( + len(snapshot_outcome_keys) == 1, + f"browser.reload(snapshot_after) expected exactly one snapshot outcome: {reload_payload}", + ) + snapshot_outcome_key = snapshot_outcome_keys[0] + snapshot_outcome = reload_payload[snapshot_outcome_key] + if snapshot_outcome_key == "post_action_snapshot": + _must( + bool(snapshot_outcome), + f"browser.reload(snapshot_after) returned an empty snapshot: {reload_payload}", + ) + else: + _must( + isinstance(snapshot_outcome, dict) and bool(snapshot_outcome), + f"browser.reload(snapshot_after) returned an invalid snapshot error: {reload_payload}", + ) _wait_with_fallback( c, target, @@ -382,6 +461,46 @@ def main() -> int: lambda: c._call("browser.click", {"surface_id": target}), "invalid_params", ) + _expect_error( + "get.text missing selector", + lambda: c._call("browser.get.text", {"surface_id": target}), + "invalid_params", + ) + _expect_error( + "eval missing script", + lambda: c._call("browser.eval", {"surface_id": target}), + "invalid_params", + ) + _expect_error( + "type missing text", + lambda: c._call("browser.type", {"surface_id": target, "selector": "#name"}), + "invalid_params", + ) + _expect_error( + "fill missing text/value", + lambda: c._call("browser.fill", {"surface_id": target, "selector": "#name"}), + "invalid_params", + ) + _expect_error( + "select missing value/text", + lambda: c._call("browser.select", {"surface_id": target, "selector": "#sel"}), + "invalid_params", + ) + _expect_error( + "press missing key", + lambda: c._call("browser.press", {"surface_id": target}), + "invalid_params", + ) + _expect_error( + "keydown missing key", + lambda: c._call("browser.keydown", {"surface_id": target}), + "invalid_params", + ) + _expect_error( + "keyup missing key", + lambda: c._call("browser.keyup", {"surface_id": target}), + "invalid_params", + ) _expect_error_contains( "click missing element", lambda: c._call("browser.click", {"surface_id": target, "selector": "#does-not-exist"}), @@ -389,6 +508,13 @@ def main() -> int: "snapshot", "hint", ) + _expect_error_contains( + "scroll missing element", + lambda: c._call("browser.scroll", {"surface_id": target, "selector": "#does-not-exist"}), + "not_found", + "snapshot", + "hint", + ) _expect_error( "get.attr missing attr", lambda: c._call("browser.get.attr", {"surface_id": target, "selector": "#status"}), @@ -404,12 +530,29 @@ def main() -> int: lambda: c._call("browser.navigate", {"surface_id": target}), "invalid_params", ) + _expect_exact_error( + "browser.back missing surface_id", + lambda: c._call("browser.back", {}), + "invalid_params: Missing or invalid surface_id", + ) + _expect_exact_error( + "browser.url.get missing surface_id", + lambda: c._call("browser.url.get", {}), + "invalid_params: Missing or invalid surface_id", + ) terminal_surface = c.new_surface(panel_type="terminal") - _expect_error( - "browser method on terminal surface", + _expect_error_contains( + "browser.forward on terminal surface", + lambda: c._call("browser.forward", {"surface_id": terminal_surface}), + "not_found", + terminal_surface, + ) + _expect_error_contains( + "browser.url.get on terminal surface", lambda: c._call("browser.url.get", {"surface_id": terminal_surface}), "not_found", + terminal_surface, ) print("PASS: comprehensive browser.* coverage (ported/adapted from agent-browser) is green") diff --git a/tests_v2/test_browser_api_extended_families.py b/tests_v2/test_browser_api_extended_families.py index 2a39da67..45d846fa 100644 --- a/tests_v2/test_browser_api_extended_families.py +++ b/tests_v2/test_browser_api_extended_families.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Extended browser.* coverage for newly added agent-browser parity families.""" +import ast import base64 import http.server import os @@ -35,6 +36,23 @@ def _expect_error_contains(label: str, fn, needle: str) -> None: raise cmuxError(f"{label}: expected error containing {needle!r}, but call succeeded") +def _expect_error_data(label: str, fn, code: str) -> dict: + try: + fn() + except cmuxError as exc: + text = str(exc) + _must(text.startswith(f"{code}:"), f"{label}: expected {code}, got: {text}") + _, separator, serialized = text.partition(" (") + _must(bool(separator) and serialized.endswith(")"), f"{label}: expected error data, got: {text}") + try: + data = ast.literal_eval(serialized[:-1]) + except (SyntaxError, ValueError) as parse_error: + raise cmuxError(f"{label}: expected parseable error data, got: {text}") from parse_error + _must(isinstance(data, dict), f"{label}: expected dictionary error data, got: {data}") + return data + raise cmuxError(f"{label}: expected {code}, but call succeeded") + + def _wait_selector(c: cmux, surface_id: str, selector: str, timeout_s: float = 6.0) -> None: timeout_ms = max(1, int(timeout_s * 1000.0)) try: @@ -72,6 +90,456 @@ def _wait_function(c: cmux, surface_id: str, expression: str, timeout_s: float = raise cmuxError(f"Timed out waiting for function: {expression}") +def _test_browser_wait_allows_concurrent_exact_query(c: cmux, surface_id: str) -> None: + """A pending browser.wait must not monopolize command dispatch or the main actor.""" + entered_attribute = "data-programa-concurrent-wait-entered" + release_attribute = "data-programa-concurrent-wait-release" + wait_result: dict = {} + wait_errors: list[Exception] = [] + wait_finished = threading.Event() + + initialized = c._call( + "browser.eval", + { + "surface_id": surface_id, + "script": ( + f"document.documentElement.removeAttribute('{entered_attribute}'); " + f"document.documentElement.removeAttribute('{release_attribute}'); true" + ), + }, + ) or {} + _must(bool(initialized.get("value")), f"Failed to initialize browser.wait handshake: {initialized}") + + def _wait_on_client_a() -> None: + try: + with cmux(SOCKET_PATH) as wait_client: + payload = wait_client._call( + "browser.wait", + { + "surface_id": surface_id, + "function": ( + "(() => { const root = document.documentElement; " + f"if (root.getAttribute('{entered_attribute}') !== 'true') {{ " + f"root.setAttribute('{entered_attribute}', 'true'); }} " + f"return root.getAttribute('{release_attribute}') === 'true'; }})()" + ), + "timeout_ms": 8_000, + }, + timeout_s=12.0, + ) or {} + wait_result.update(payload) + except Exception as exc: # pragma: no cover - surfaced on the controlling thread + wait_errors.append(exc) + finally: + wait_finished.set() + + wait_thread = threading.Thread(target=_wait_on_client_a, daemon=True) + wait_thread.start() + + primary_error: Exception | None = None + cleanup_errors: list[Exception] = [] + entered = False + try: + entry_deadline = time.monotonic() + 4.0 + while time.monotonic() < entry_deadline: + _must( + not wait_finished.is_set(), + f"browser.wait returned before publishing its entered marker: {wait_errors or wait_result}", + ) + probe = c._call( + "browser.eval", + { + "surface_id": surface_id, + "script": ( + f"document.documentElement.getAttribute('{entered_attribute}') === 'true'" + ), + }, + timeout_s=2.0, + ) or {} + if bool(probe.get("value")): + entered = True + break + wait_finished.wait(timeout=0.02) + + _must(entered, "Timed out waiting for browser.wait to publish its entered marker") + _must(not wait_finished.is_set(), "browser.wait must remain pending until its release flag is set") + + query_started = time.monotonic() + window_payload = c._call("window.list", timeout_s=2.0) or {} + windows = list(window_payload.get("windows") or []) + query_elapsed = time.monotonic() - query_started + _must(bool(windows), f"window.list should return the running Programa window: {windows}") + _must( + query_elapsed < 2.0, + f"window.list must complete promptly while browser.wait is pending; took {query_elapsed:.2f}s", + ) + _must( + not wait_finished.is_set(), + "Client A's browser.wait must still be pending when client B's exact query returns", + ) + except Exception as exc: + primary_error = exc + finally: + try: + released = c._call( + "browser.eval", + { + "surface_id": surface_id, + "script": ( + f"document.documentElement.setAttribute('{release_attribute}', 'true'); true" + ), + }, + timeout_s=3.0, + ) or {} + if not bool(released.get("value")): + cleanup_errors.append(cmuxError(f"Failed to release browser.wait: {released}")) + except Exception as exc: # pragma: no cover - reported below + cleanup_errors.append(exc) + + wait_thread.join(timeout=10.0) + + try: + c._call( + "browser.eval", + { + "surface_id": surface_id, + "script": ( + f"document.documentElement.removeAttribute('{entered_attribute}'); " + f"document.documentElement.removeAttribute('{release_attribute}'); true" + ), + }, + timeout_s=3.0, + ) + except Exception as exc: # pragma: no cover - reported below + cleanup_errors.append(exc) + + if primary_error is not None: + raise cmuxError( + f"{primary_error}; wait_errors={wait_errors}; wait_result={wait_result}; " + f"cleanup_errors={cleanup_errors}; wait_thread_alive={wait_thread.is_alive()}" + ) from primary_error + _must(not cleanup_errors, f"browser.wait cleanup failed: {cleanup_errors}") + _must(not wait_thread.is_alive(), "browser.wait client thread did not join after release") + _must(not wait_errors, f"browser.wait client failed: {wait_errors}") + _must(wait_finished.is_set(), "browser.wait client did not finish after release") + _must(wait_result.get("waited") is True, f"Expected browser.wait waited=true: {wait_result}") + + +def _test_download_path_wait_allows_concurrent_exact_query(c: cmux, surface_id: str) -> None: + """A pending path wait must not block exact queries or poison later requests.""" + wait_finished = threading.Event() + wait_result: dict = {} + wait_errors: list[Exception] = [] + + with tempfile.TemporaryDirectory(prefix="cmux-download-wait-") as root: + download_path = str(Path(root) / "pending.txt") + pending_marker_path = str(Path(root) / "watcher-ready.txt") + + def _wait_on_client_a() -> None: + try: + with cmux(SOCKET_PATH) as wait_client: + payload = wait_client._call( + "browser.download.wait", + { + "surface_id": surface_id, + "path": download_path, + "timeout_ms": 8_000, + "_test_pending_marker_path": pending_marker_path, + }, + timeout_s=12.0, + ) or {} + wait_result.update(payload) + except Exception as exc: # pragma: no cover - surfaced on the controlling thread + wait_errors.append(exc) + finally: + wait_finished.set() + + wait_thread = threading.Thread(target=_wait_on_client_a, daemon=True) + wait_thread.start() + + primary_error: Exception | None = None + cleanup_errors: list[Exception] = [] + try: + marker_observed = False + marker_deadline = time.monotonic() + 4.0 + while time.monotonic() < marker_deadline: + try: + marker_observed = Path(pending_marker_path).stat().st_size > 0 + except FileNotFoundError: + marker_observed = False + if marker_observed: + break + _must( + not wait_finished.is_set(), + f"browser.download.wait returned before publishing its pending marker: {wait_errors or wait_result}", + ) + wait_finished.wait(timeout=0.02) + + _must(marker_observed, "Timed out waiting for browser.download.wait pending marker") + _must( + not wait_finished.is_set(), + f"browser.download.wait returned before its target existed: {wait_errors or wait_result}", + ) + + query_started = time.monotonic() + window_payload = c._call("window.list", timeout_s=2.0) or {} + query_elapsed = time.monotonic() - query_started + _must(bool(window_payload.get("windows")), f"window.list should return the running Programa window: {window_payload}") + _must( + query_elapsed < 2.0, + f"window.list must complete promptly while browser.download.wait is pending; took {query_elapsed:.2f}s", + ) + _must( + not wait_finished.is_set(), + "Client A's browser.download.wait must remain pending when client B's exact query returns", + ) + except Exception as exc: + primary_error = exc + finally: + try: + Path(download_path).write_text("downloaded", encoding="utf-8") + except Exception as exc: # pragma: no cover - reported below + cleanup_errors.append(exc) + wait_thread.join(timeout=10.0) + + if primary_error is not None: + raise cmuxError( + f"{primary_error}; wait_errors={wait_errors}; wait_result={wait_result}; " + f"cleanup_errors={cleanup_errors}; wait_thread_alive={wait_thread.is_alive()}" + ) from primary_error + _must(not cleanup_errors, f"browser.download.wait cleanup failed: {cleanup_errors}") + _must(not wait_thread.is_alive(), "browser.download.wait client thread did not join after file creation") + _must(not wait_errors, f"browser.download.wait client failed: {wait_errors}") + _must(wait_finished.is_set(), "browser.download.wait client did not finish after file creation") + _must(wait_result.get("downloaded") is True, f"Expected browser.download.wait downloaded=true: {wait_result}") + + timeout_path = str(Path(root) / "timeout.txt") + timeout_data = _expect_error_data( + "download path wait timeout", + lambda: c._call( + "browser.download.wait", + {"surface_id": surface_id, "path": timeout_path, "timeout_ms": 100}, + timeout_s=3.0, + ), + "timeout", + ) + _must(timeout_data.get("path") == timeout_path, f"Expected timed-out download path: {timeout_data}") + _must(timeout_data.get("timeout_ms") == 100, f"Expected exact download timeout: {timeout_data}") + + recovery_payload = c._call("window.list", timeout_s=2.0) or {} + _must( + bool(recovery_payload.get("windows")), + f"window.list should recover after browser.download.wait timeout: {recovery_payload}", + ) + + +def _test_browser_screenshot_allows_concurrent_exact_query(c: cmux, surface_id: str) -> None: + """Completed screenshot work must not hold the main actor while routing its response.""" + + def _wait_for_pending_marker( + marker_path: str, + finished: threading.Event, + errors: list[Exception], + result: dict, + label: str, + ) -> None: + marker_observed = False + marker_deadline = time.monotonic() + 4.0 + while time.monotonic() < marker_deadline: + try: + marker_observed = Path(marker_path).stat().st_size > 0 + except FileNotFoundError: + marker_observed = False + if marker_observed: + break + _must( + not finished.is_set(), + f"{label} returned before publishing its pending marker: {errors or result}", + ) + finished.wait(timeout=0.02) + _must(marker_observed, f"Timed out waiting for {label} pending marker") + + with tempfile.TemporaryDirectory(prefix="cmux-screenshot-wait-") as root: + success_pending_path = str(Path(root) / "success-pending.txt") + success_release_path = str(Path(root) / "success-release.txt") + success_result: dict = {} + success_errors: list[Exception] = [] + success_finished = threading.Event() + + def _take_released_screenshot() -> None: + try: + with cmux(SOCKET_PATH) as screenshot_client: + payload = screenshot_client._call( + "browser.screenshot", + { + "surface_id": surface_id, + "_test_screenshot_pending_marker_path": success_pending_path, + "_test_screenshot_release_marker_path": success_release_path, + }, + timeout_s=10.0, + ) or {} + success_result.update(payload) + except Exception as exc: # pragma: no cover - surfaced on the controlling thread + success_errors.append(exc) + finally: + success_finished.set() + + success_thread = threading.Thread(target=_take_released_screenshot, daemon=True) + success_thread.start() + + success_primary_error: Exception | None = None + success_cleanup_errors: list[Exception] = [] + try: + _wait_for_pending_marker( + success_pending_path, + success_finished, + success_errors, + success_result, + "browser.screenshot", + ) + _must(not success_finished.is_set(), "browser.screenshot must remain gated before release") + + query_started = time.monotonic() + window_payload = c._call("window.list", timeout_s=2.0) or {} + query_elapsed = time.monotonic() - query_started + _must(bool(window_payload.get("windows")), f"window.list should return the running Programa window: {window_payload}") + _must( + query_elapsed < 2.0, + f"window.list must complete promptly while browser.screenshot is gated; took {query_elapsed:.2f}s", + ) + _must( + not success_finished.is_set(), + "Client A's browser.screenshot must remain pending when client B's exact query returns", + ) + except Exception as exc: + success_primary_error = exc + finally: + try: + Path(success_release_path).write_text("release", encoding="utf-8") + except Exception as exc: # pragma: no cover - reported below + success_cleanup_errors.append(exc) + success_thread.join(timeout=10.0) + + screenshot_path_value = str(success_result.get("path") or "") + screenshot_path = Path(screenshot_path_value) if screenshot_path_value else None + screenshot_path_existed = screenshot_path is not None and screenshot_path.is_file() + success_post_join_error = success_primary_error + try: + if success_post_join_error is None: + _must(not success_thread.is_alive(), "browser.screenshot client thread did not join after release") + _must(not success_errors, f"browser.screenshot client failed: {success_errors}") + _must(success_finished.is_set(), "browser.screenshot client did not finish after release") + + png_base64 = str(success_result.get("png_base64") or "") + _must(len(png_base64) > 100, f"Expected non-trivial screenshot payload: {success_result}") + _must(success_result.get("surface_id") == surface_id, f"Expected screenshot surface_id={surface_id}: {success_result}") + _must(bool(str(success_result.get("workspace_id") or "")), f"Expected screenshot workspace_id: {success_result}") + _must(screenshot_path_existed, f"Expected screenshot file to exist: {success_result}") + _must(str(success_result.get("url") or "").startswith("file://"), f"Expected screenshot file URL: {success_result}") + except Exception as exc: + success_post_join_error = exc + finally: + if screenshot_path is not None and screenshot_path.is_file(): + try: + screenshot_path.unlink() + except Exception as exc: # pragma: no cover - reported below + success_cleanup_errors.append(exc) + if success_post_join_error is not None: + raise cmuxError( + f"{success_post_join_error}; screenshot_errors={success_errors}; screenshot_result={success_result}; " + f"cleanup_errors={success_cleanup_errors}; screenshot_thread_alive={success_thread.is_alive()}" + ) from success_post_join_error + _must(not success_cleanup_errors, f"browser.screenshot cleanup failed: {success_cleanup_errors}") + + timeout_pending_path = str(Path(root) / "timeout-pending.txt") + timeout_release_path = str(Path(root) / "timeout-release.txt") + timeout_result: dict = {} + timeout_errors: list[Exception] = [] + timeout_finished = threading.Event() + + def _take_timed_out_screenshot() -> None: + try: + with cmux(SOCKET_PATH) as screenshot_client: + payload = screenshot_client._call( + "browser.screenshot", + { + "surface_id": surface_id, + "_test_screenshot_pending_marker_path": timeout_pending_path, + "_test_screenshot_release_marker_path": timeout_release_path, + }, + timeout_s=12.0, + ) or {} + timeout_result.update(payload) + except Exception as exc: # pragma: no cover - surfaced on the controlling thread + timeout_errors.append(exc) + finally: + timeout_finished.set() + + timeout_thread = threading.Thread(target=_take_timed_out_screenshot, daemon=True) + timeout_thread.start() + + timeout_primary_error: Exception | None = None + timeout_cleanup_errors: list[Exception] = [] + try: + _wait_for_pending_marker( + timeout_pending_path, + timeout_finished, + timeout_errors, + timeout_result, + "timed browser.screenshot", + ) + _must(not timeout_finished.is_set(), "Timed browser.screenshot must remain gated before its deadline") + timeout_thread.join(timeout=7.0) + _must(not timeout_thread.is_alive(), "Timed browser.screenshot did not return after its fixed deadline") + _must(timeout_finished.is_set(), "Timed browser.screenshot thread did not finish") + _must(not timeout_result, f"Timed browser.screenshot unexpectedly succeeded: {timeout_result}") + _must(len(timeout_errors) == 1, f"Expected one browser.screenshot timeout error: {timeout_errors}") + _must( + isinstance(timeout_errors[0], cmuxError) + and str(timeout_errors[0]) == "timeout: Timed out waiting for snapshot", + f"Expected exact browser.screenshot timeout error: {timeout_errors}", + ) + except Exception as exc: + timeout_primary_error = exc + finally: + try: + Path(timeout_release_path).write_text("release", encoding="utf-8") + except Exception as exc: # pragma: no cover - reported below + timeout_cleanup_errors.append(exc) + timeout_thread.join(timeout=3.0) + threading.Event().wait(timeout=0.5) + + if timeout_primary_error is not None: + raise cmuxError( + f"{timeout_primary_error}; screenshot_errors={timeout_errors}; screenshot_result={timeout_result}; " + f"cleanup_errors={timeout_cleanup_errors}; screenshot_thread_alive={timeout_thread.is_alive()}" + ) from timeout_primary_error + _must(not timeout_cleanup_errors, f"Timed browser.screenshot release cleanup failed: {timeout_cleanup_errors}") + _must(not timeout_thread.is_alive(), "Timed browser.screenshot client thread remained alive after release") + + recovery_payload = c._call("window.list", timeout_s=2.0) or {} + _must( + bool(recovery_payload.get("windows")), + f"window.list should recover after browser.screenshot timeout: {recovery_payload}", + ) + recovery_screenshot = c._call("browser.screenshot", {"surface_id": surface_id}, timeout_s=8.0) or {} + recovery_path_value = str(recovery_screenshot.get("path") or "") + recovery_path = Path(recovery_path_value) if recovery_path_value else None + recovery_cleanup_errors: list[Exception] = [] + try: + _must( + len(str(recovery_screenshot.get("png_base64") or "")) > 100, + f"Expected normal screenshot after timeout: {recovery_screenshot}", + ) + finally: + if recovery_path is not None and recovery_path.is_file(): + try: + recovery_path.unlink() + except Exception as exc: # pragma: no cover - reported below + recovery_cleanup_errors.append(exc) + _must(not recovery_cleanup_errors, f"Normal browser.screenshot cleanup failed: {recovery_cleanup_errors}") + + @contextmanager def _local_test_server() -> str: with tempfile.TemporaryDirectory(prefix="cmux-browser-ext-") as root: @@ -136,6 +604,15 @@ def _local_test_server() -> str: